diff --git a/.coveragerc b/.coveragerc index 4be573201d6ee6..378532dfd88cfd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -73,6 +73,10 @@ omit = homeassistant/components/apple_tv/browse_media.py homeassistant/components/apple_tv/media_player.py homeassistant/components/apple_tv/remote.py + homeassistant/components/aprilaire/__init__.py + homeassistant/components/aprilaire/climate.py + homeassistant/components/aprilaire/coordinator.py + homeassistant/components/aprilaire/entity.py homeassistant/components/aqualogic/* homeassistant/components/aquostv/media_player.py homeassistant/components/arcam_fmj/__init__.py @@ -89,7 +93,7 @@ omit = homeassistant/components/aseko_pool_live/entity.py homeassistant/components/aseko_pool_live/sensor.py homeassistant/components/asterisk_cdr/mailbox.py - homeassistant/components/asterisk_mbox/* + homeassistant/components/asterisk_mbox/mailbox.py homeassistant/components/aten_pe/* homeassistant/components/atome/* homeassistant/components/aurora/__init__.py @@ -188,6 +192,7 @@ omit = homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py + homeassistant/components/comelit/humidifier.py homeassistant/components/comelit/light.py homeassistant/components/comelit/sensor.py homeassistant/components/comelit/switch.py @@ -359,7 +364,6 @@ omit = homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py - homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/eufylife_ble/__init__.py @@ -555,6 +559,7 @@ omit = homeassistant/components/hunterdouglas_powerview/coordinator.py homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/number.py homeassistant/components/hunterdouglas_powerview/select.py homeassistant/components/hunterdouglas_powerview/sensor.py homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -634,12 +639,6 @@ omit = homeassistant/components/izone/climate.py homeassistant/components/izone/discovery.py homeassistant/components/joaoapps_join/* - homeassistant/components/juicenet/__init__.py - homeassistant/components/juicenet/device.py - homeassistant/components/juicenet/entity.py - homeassistant/components/juicenet/number.py - homeassistant/components/juicenet/sensor.py - homeassistant/components/juicenet/switch.py homeassistant/components/justnimbus/coordinator.py homeassistant/components/justnimbus/entity.py homeassistant/components/justnimbus/sensor.py @@ -765,6 +764,16 @@ omit = homeassistant/components/meteoclimatic/__init__.py homeassistant/components/meteoclimatic/sensor.py homeassistant/components/meteoclimatic/weather.py + homeassistant/components/microbees/__init__.py + homeassistant/components/microbees/api.py + homeassistant/components/microbees/application_credentials.py + homeassistant/components/microbees/button.py + homeassistant/components/microbees/const.py + homeassistant/components/microbees/coordinator.py + homeassistant/components/microbees/entity.py + homeassistant/components/microbees/light.py + homeassistant/components/microbees/sensor.py + homeassistant/components/microbees/switch.py homeassistant/components/microsoft/tts.py homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py @@ -874,6 +883,7 @@ omit = homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/sensor.py + homeassistant/components/notion/util.py homeassistant/components/nsw_fuel_station/sensor.py homeassistant/components/nuki/__init__.py homeassistant/components/nuki/binary_sensor.py @@ -1067,6 +1077,7 @@ omit = homeassistant/components/renson/sensor.py homeassistant/components/renson/button.py homeassistant/components/renson/fan.py + homeassistant/components/renson/switch.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/number.py homeassistant/components/renson/time.py @@ -1535,6 +1546,7 @@ omit = homeassistant/components/vicare/entity.py homeassistant/components/vicare/number.py homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/types.py homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py @@ -1572,6 +1584,11 @@ omit = homeassistant/components/weatherflow/__init__.py homeassistant/components/weatherflow/const.py homeassistant/components/weatherflow/sensor.py + homeassistant/components/weatherflow_cloud/__init__.py + homeassistant/components/weatherflow_cloud/const.py + homeassistant/components/weatherflow_cloud/coordinator.py + homeassistant/components/weatherflow_cloud/weather.py + homeassistant/components/webmin/sensor.py homeassistant/components/wiffi/__init__.py homeassistant/components/wiffi/binary_sensor.py homeassistant/components/wiffi/sensor.py @@ -1695,6 +1712,7 @@ omit = homeassistant/components/myuplink/application_credentials.py homeassistant/components/myuplink/coordinator.py homeassistant/components/myuplink/entity.py + homeassistant/components/myuplink/helpers.py homeassistant/components/myuplink/sensor.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 16a48d3cb48dc4..333c31ce8415e0 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -103,7 +103,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v3.0.0 + uses: dawidd6/action-download-artifact@v3.1.2 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -114,7 +114,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v3.0.0 + uses: dawidd6/action-download-artifact@v3.1.2 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package @@ -341,7 +341,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.3.0 + uses: sigstore/cosign-installer@v3.4.0 with: cosign-release: "v2.0.2" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f7854ef88df6e5..9a898d11aa5dc7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 7 - HA_SHORT_VERSION: "2024.2" + HA_SHORT_VERSION: "2024.3" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version @@ -103,7 +103,7 @@ jobs: echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{ hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Filter for core changes - uses: dorny/paths-filter@v3.0.0 + uses: dorny/paths-filter@v3.0.1 id: core with: filters: .core_files.yaml @@ -118,7 +118,7 @@ jobs: echo "Result:" cat .integration_paths.yaml - name: Filter for integration changes - uses: dorny/paths-filter@v3.0.0 + uses: dorny/paths-filter@v3.0.1 id: integrations with: filters: .integration_paths.yaml @@ -803,10 +803,11 @@ jobs: path: pytest-*.txt - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4.3.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml + overwrite: true - name: Check dirty run: | ./script/check_dirty @@ -928,11 +929,12 @@ jobs: path: pytest-*.txt - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4.3.1 with: - name: coverage-${{ matrix.python-version }}-mariadb-${{ + name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} path: coverage.xml + overwrite: true - name: Check dirty run: | ./script/check_dirty @@ -1055,11 +1057,12 @@ jobs: path: pytest-*.txt - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v3.1.0 + uses: actions/upload-artifact@v4.3.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} path: coverage.xml + overwrite: true - name: Check dirty run: | ./script/check_dirty @@ -1076,10 +1079,12 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Download all coverage artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4.1.3 + with: + pattern: coverage-* - name: Upload coverage to Codecov (full coverage) if: needs.info.outputs.test_full_suite == 'true' - uses: Wandalen/wretry.action@v1.3.0 + uses: Wandalen/wretry.action@v1.4.4 with: action: codecov/codecov-action@v3.1.3 with: | @@ -1090,7 +1095,7 @@ jobs: attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) if: needs.info.outputs.test_full_suite == 'false' - uses: Wandalen/wretry.action@v1.3.0 + uses: Wandalen/wretry.action@v1.4.4 with: action: codecov/codecov-action@v3.1.3 with: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index bdec74a3affe6d..f7d97de0022e82 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,11 +2,6 @@ name: "CodeQL" # yamllint disable-line rule:truthy on: - push: - branches: - - dev - - rc - - master schedule: - cron: "30 18 * * 4" @@ -29,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.23.2 + uses: github/codeql-action/init@v3.24.5 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.23.2 + uses: github/codeql-action/analyze@v3.24.5 with: category: "/language:python" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c9b1a76cc37ca1..bae60e8e945d4a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -63,16 +63,18 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4.3.1 with: name: env_file path: ./.env_file + overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v3.1.2 + uses: actions/upload-artifact@v4.3.1 with: name: requirements_diff path: ./requirements_diff.txt + overwrite: true core: name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2) @@ -82,19 +84,19 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311", "cp312"] + abi: ["cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository uses: actions/checkout@v4.1.1 - name: Download env_file - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4.1.3 with: name: env_file - name: Download requirements_diff - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4.1.3 with: name: requirements_diff @@ -120,19 +122,19 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311", "cp312"] + abi: ["cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository uses: actions/checkout@v4.1.1 - name: Download env_file - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4.1.3 with: name: env_file - name: Download requirements_diff - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4.1.3 with: name: requirements_diff diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0db0244edc964e..4b96b5ee2aa366 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.8 + rev: v0.2.1 hooks: - id: ruff args: diff --git a/.strict-typing b/.strict-typing index bd92da2fc505be..74535719bb356d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -80,6 +80,7 @@ homeassistant.components.anthemav.* homeassistant.components.apache_kafka.* homeassistant.components.apcupsd.* homeassistant.components.api.* +homeassistant.components.apple_tv.* homeassistant.components.apprise.* homeassistant.components.aprs.* homeassistant.components.aqualogic.* diff --git a/CODEOWNERS b/CODEOWNERS index 144883db68f300..1424469a94bbe4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -104,6 +104,8 @@ build.json @home-assistant/supervisor /tests/components/application_credentials/ @home-assistant/core /homeassistant/components/apprise/ @caronc /tests/components/apprise/ @caronc +/homeassistant/components/aprilaire/ @chamberlain2007 +/tests/components/aprilaire/ @chamberlain2007 /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW /homeassistant/components/aranet/ @aschmitz @thecode @@ -157,8 +159,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @riokuu -/tests/components/blebox/ @bbx-a @riokuu +/homeassistant/components/blebox/ @bbx-a @riokuu @swistakm +/tests/components/blebox/ @bbx-a @riokuu @swistakm /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer /homeassistant/components/blue_current/ @Floris272 @gleeuwen @@ -329,8 +331,8 @@ build.json @home-assistant/supervisor /tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @w1ll1am23 /tests/components/econet/ @w1ll1am23 -/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus -/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus +/homeassistant/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar +/tests/components/ecovacs/ @OverloadUT @mib1185 @edenhaus @Augar /homeassistant/components/ecowitt/ @pvizeli /tests/components/ecowitt/ @pvizeli /homeassistant/components/efergy/ @tkdrob @@ -584,6 +586,8 @@ build.json @home-assistant/supervisor /tests/components/humidifier/ @home-assistant/core @Shulyaka /homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/homeassistant/components/husqvarna_automower/ @Thomas55555 +/tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/huum/ @frwickst /tests/components/huum/ @frwickst /homeassistant/components/hvv_departures/ @vigonotion @@ -665,8 +669,6 @@ build.json @home-assistant/supervisor /tests/components/jellyfin/ @j-stienstra @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi -/homeassistant/components/juicenet/ @jesserockz -/tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/jvc_projector/ @SteveEasley @msavazzi @@ -766,8 +768,8 @@ build.json @home-assistant/supervisor /tests/components/lupusec/ @majuss @suaveolent /homeassistant/components/lutron/ @cdheiser @wilburCForce /tests/components/lutron/ @cdheiser @wilburCForce -/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues -/tests/components/lutron_caseta/ @swails @bdraco @danaues +/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 +/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff @@ -801,6 +803,8 @@ build.json @home-assistant/supervisor /tests/components/meteoclimatic/ @adrianmo /homeassistant/components/metoffice/ @MrHarcombe @avee87 /tests/components/metoffice/ @MrHarcombe @avee87 +/homeassistant/components/microbees/ @microBeesTech +/tests/components/microbees/ @microBeesTech /homeassistant/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87 /homeassistant/components/mill/ @danielhiversen @@ -848,8 +852,8 @@ build.json @home-assistant/supervisor /tests/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mystrom/ @fabaff /tests/components/mystrom/ @fabaff -/homeassistant/components/myuplink/ @pajzo -/tests/components/myuplink/ @pajzo +/homeassistant/components/myuplink/ @pajzo @astrandb +/tests/components/myuplink/ @pajzo @astrandb /homeassistant/components/nam/ @bieniu /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu @@ -967,8 +971,8 @@ build.json @home-assistant/supervisor /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund -/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev -/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev +/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 +/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev @tronix117 /homeassistant/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001 /homeassistant/components/p1_monitor/ @klaasnicolaas @@ -1125,8 +1129,8 @@ build.json @home-assistant/supervisor /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter /tests/components/romy/ @xeniter -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 @Orhideous +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 @Orhideous /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/rpi_power/ @shenxn @swetoast @@ -1452,13 +1456,14 @@ build.json @home-assistant/supervisor /tests/components/v2c/ @dgomes /homeassistant/components/vacuum/ @home-assistant/core /tests/components/vacuum/ @home-assistant/core -/homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- -/tests/components/vallox/ @andre-richter @slovdahl @viiru- +/homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04 +/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04 /homeassistant/components/valve/ @home-assistant/core /tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 +/homeassistant/components/velux/ @Julius2342 @DeerMaximum +/tests/components/velux/ @Julius2342 @DeerMaximum /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz @@ -1504,10 +1509,14 @@ build.json @home-assistant/supervisor /tests/components/weather/ @home-assistant/core /homeassistant/components/weatherflow/ @natekspencer @jeeftor /tests/components/weatherflow/ @natekspencer @jeeftor +/homeassistant/components/weatherflow_cloud/ @jeeftor +/tests/components/weatherflow_cloud/ @jeeftor /homeassistant/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core +/homeassistant/components/webmin/ @autinerd +/tests/components/webmin/ @autinerd /homeassistant/components/webostv/ @thecode /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core diff --git a/build.yaml b/build.yaml index d0baa4ac18ee86..f6ffac3bd1de3e 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.02.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.02.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.02.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.02.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.02.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cc3d87319d078d..4fc9073b1462d4 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -3,9 +3,10 @@ import asyncio import contextlib -from datetime import datetime, timedelta +from datetime import timedelta import logging import logging.handlers +from operator import itemgetter import os import platform import sys @@ -13,13 +14,28 @@ from time import monotonic from typing import TYPE_CHECKING, Any +# Import cryptography early since import openssl is not thread-safe +# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend') +import cryptography.hazmat.backends.openssl.backend # noqa: F401 import voluptuous as vol import yarl from . import config as conf_util, config_entries, core, loader, requirements -from .components import http + +# Pre-import config and lovelace which have no requirements here to avoid +# loading them at run time and blocking the event loop. We do this ahead +# of time so that we do not have to flag frontends deps with `import_executor` +# as it would create a thundering heard of executor jobs trying to import +# frontend deps at the same time. +from .components import ( + api as api_pre_import, # noqa: F401 + config as config_pre_import, # noqa: F401 + http, + lovelace as lovelace_pre_import, # noqa: F401 +) from .const import ( FORMAT_DATETIME, + KEY_DATA_LOGGING as DATA_LOGGING, REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, SIGNAL_BOOTSTRAP_INTEGRATIONS, @@ -31,21 +47,25 @@ device_registry, entity, entity_registry, + floor_registry, issue_registry, + label_registry, recorder, restore_state, template, + translation, ) from .helpers.dispatcher import async_dispatcher_send from .helpers.typing import ConfigType from .setup import ( + BASE_PLATFORMS, DATA_SETUP_STARTED, DATA_SETUP_TIME, async_notify_setup_error, async_set_domains_to_be_loaded, async_setup_component, ) -from .util import dt as dt_util +from .util.async_ import create_eager_task from .util.logging import async_activate_log_queue_handler from .util.package import async_get_user_site, is_virtual_env @@ -57,7 +77,6 @@ ERROR_LOG_FILENAME = "home-assistant.log" # hass.data key for logging information. -DATA_LOGGING = "logging" DATA_REGISTRIES_LOADED = "bootstrap_registries_loaded" LOG_SLOW_STARTUP_INTERVAL = 60 @@ -110,6 +129,7 @@ # # Integrations providing core functionality: "application_credentials", + "backup", "frontend", "hardware", "logger", @@ -143,15 +163,22 @@ # These integrations are set up if using the Supervisor "hassio", } -DEFAULT_INTEGRATIONS_NON_SUPERVISOR = { - # These integrations are set up if not using the Supervisor - "backup", -} CRITICAL_INTEGRATIONS = { # Recovery mode is activated if these integrations fail to set up "frontend", } +SETUP_ORDER = { + # Load logging as soon as possible + "logging": LOGGING_INTEGRATIONS, + # Setup frontend + "frontend": FRONTEND_INTEGRATIONS, + # Setup recorder + "recorder": RECORDER_INTEGRATIONS, + # Start up debuggers. Start these first in case they want to wait. + "debugger": DEBUGGER_INTEGRATIONS, +} + async def async_setup_hass( runtime_config: RuntimeConfig, @@ -217,7 +244,7 @@ async def async_setup_hass( ) # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with hass.timeout.async_timeout(10): await hass.async_stop() @@ -291,17 +318,20 @@ def _cache_uname_processor() -> None: platform.uname().processor # pylint: disable=expression-not-assigned # Load the registries and cache the result of platform.uname().processor + translation.async_setup(hass) 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), + create_eager_task(area_registry.async_load(hass)), + create_eager_task(device_registry.async_load(hass)), + create_eager_task(entity_registry.async_load(hass)), + create_eager_task(floor_registry.async_load(hass)), + create_eager_task(issue_registry.async_load(hass)), + create_eager_task(label_registry.async_load(hass)), hass.async_add_executor_job(_cache_uname_processor), - template.async_load_custom_templates(hass), - restore_state.async_load(hass), - hass.config_entries.async_initialize(), + create_eager_task(template.async_load_custom_templates(hass)), + create_eager_task(restore_state.async_load(hass)), + create_eager_task(hass.config_entries.async_initialize()), ) @@ -324,7 +354,7 @@ async def async_from_config_dict( if not all( await asyncio.gather( *( - async_setup_component(hass, domain, config) + create_eager_task(async_setup_component(hass, domain, config)) for domain in CORE_INTEGRATIONS ) ) @@ -533,42 +563,73 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: # Add domains depending on if the Supervisor is used or not if "SUPERVISOR" in os.environ: domains.update(DEFAULT_INTEGRATIONS_SUPERVISOR) - else: - domains.update(DEFAULT_INTEGRATIONS_NON_SUPERVISOR) return domains -async def _async_watch_pending_setups(hass: core.HomeAssistant) -> None: - """Periodic log of setups that are pending. +class _WatchPendingSetups: + """Periodic log and dispatch of setups that are pending.""" + + def __init__( + self, hass: core.HomeAssistant, setup_started: dict[str, float] + ) -> None: + """Initialize the WatchPendingSetups class.""" + self._hass = hass + self._setup_started = setup_started + self._duration_count = 0 + self._handle: asyncio.TimerHandle | None = None + self._previous_was_empty = True + self._loop = hass.loop + + def _async_watch(self) -> None: + """Periodic log of setups that are pending.""" + now = monotonic() + self._duration_count += SLOW_STARTUP_CHECK_INTERVAL - Pending for longer than LOG_SLOW_STARTUP_INTERVAL. - """ - loop_count = 0 - setup_started: dict[str, datetime] = hass.data[DATA_SETUP_STARTED] - previous_was_empty = True - while True: - now = dt_util.utcnow() remaining_with_setup_started = { - domain: (now - setup_started[domain]).total_seconds() - for domain in setup_started + domain: (now - start_time) + for domain, start_time in self._setup_started.items() } _LOGGER.debug("Integration remaining: %s", remaining_with_setup_started) - if remaining_with_setup_started or not previous_was_empty: - async_dispatcher_send( - hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started - ) - previous_was_empty = not remaining_with_setup_started - await asyncio.sleep(SLOW_STARTUP_CHECK_INTERVAL) - loop_count += SLOW_STARTUP_CHECK_INTERVAL - - if loop_count >= LOG_SLOW_STARTUP_INTERVAL and setup_started: + self._async_dispatch(remaining_with_setup_started) + if ( + self._setup_started + and self._duration_count % LOG_SLOW_STARTUP_INTERVAL == 0 + ): + # We log every LOG_SLOW_STARTUP_INTERVAL until all integrations are done + # once we take over LOG_SLOW_STARTUP_INTERVAL (60s) to start up _LOGGER.warning( "Waiting on integrations to complete setup: %s", - ", ".join(setup_started), + ", ".join(self._setup_started), + ) + + _LOGGER.debug("Running timeout Zones: %s", self._hass.timeout.zones) + self._async_schedule_next() + + def _async_dispatch(self, remaining_with_setup_started: dict[str, float]) -> None: + """Dispatch the signal.""" + if remaining_with_setup_started or not self._previous_was_empty: + async_dispatcher_send( + self._hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, remaining_with_setup_started ) - loop_count = 0 - _LOGGER.debug("Running timeout Zones: %s", hass.timeout.zones) + self._previous_was_empty = not remaining_with_setup_started + + def _async_schedule_next(self) -> None: + """Schedule the next call.""" + self._handle = self._loop.call_later( + SLOW_STARTUP_CHECK_INTERVAL, self._async_watch + ) + + def async_start(self) -> None: + """Start watching.""" + self._async_schedule_next() + + def async_stop(self) -> None: + """Stop watching.""" + self._async_dispatch({}) + if self._handle: + self._handle.cancel() + self._handle = None async def async_setup_multi_components( @@ -581,7 +642,9 @@ async def async_setup_multi_components( domains_not_yet_setup = domains - hass.config.components futures = { domain: hass.async_create_task( - async_setup_component(hass, domain, config), f"setup component {domain}" + async_setup_component(hass, domain, config), + f"setup component {domain}", + eager_start=True, ) for domain in domains_not_yet_setup } @@ -596,17 +659,12 @@ async def async_setup_multi_components( ) -async def _async_set_up_integrations( +async def _async_resolve_domains_to_setup( hass: core.HomeAssistant, config: dict[str, Any] -) -> None: - """Set up all the integrations.""" - hass.data[DATA_SETUP_STARTED] = {} - setup_time: dict[str, timedelta] = hass.data.setdefault(DATA_SETUP_TIME, {}) - - watch_task = asyncio.create_task(_async_watch_pending_setups(hass)) - +) -> tuple[set[str], dict[str, loader.Integration]]: + """Resolve all dependencies and return list of domains to set up.""" + base_platforms_loaded = False domains_to_setup = _get_domains(hass, config) - needed_requirements: set[str] = set() # Resolve all dependencies so we know all integrations @@ -617,48 +675,58 @@ async def _async_set_up_integrations( old_to_resolve: set[str] = to_resolve to_resolve = set() - integrations_to_process = [ - int_or_exc - for int_or_exc in ( - await loader.async_get_integrations(hass, old_to_resolve) - ).values() - if isinstance(int_or_exc, loader.Integration) - ] + if not base_platforms_loaded: + # Load base platforms right away since + # we do not require the manifest to list + # them as dependencies and we want + # to avoid the lock contention when multiple + # integrations try to resolve them at once + base_platforms_loaded = True + to_get = {*old_to_resolve, *BASE_PLATFORMS} + else: + to_get = old_to_resolve manifest_deps: set[str] = set() - for itg in integrations_to_process: + resolve_dependencies_tasks: list[asyncio.Task[bool]] = [] + integrations_to_process: list[loader.Integration] = [] + + for domain, itg in (await loader.async_get_integrations(hass, to_get)).items(): + if not isinstance(itg, loader.Integration) or domain not in old_to_resolve: + continue + integrations_to_process.append(itg) + integration_cache[domain] = itg manifest_deps.update(itg.dependencies) manifest_deps.update(itg.after_dependencies) needed_requirements.update(itg.requirements) + if not itg.all_dependencies_resolved: + resolve_dependencies_tasks.append( + create_eager_task( + itg.resolve_dependencies(), + name=f"resolve dependencies {domain}", + loop=hass.loop, + ) + ) - if manifest_deps: + if unseen_deps := manifest_deps - integration_cache.keys(): # If there are dependencies, try to preload all # the integrations manifest at once and add them # to the list of requirements we need to install # so we can try to check if they are already installed # in a single call below which avoids each integration # having to wait for the lock to do it individually - deps = await loader.async_get_integrations(hass, manifest_deps) - for dependant_itg in deps.values(): + deps = await loader.async_get_integrations(hass, unseen_deps) + for dependant_domain, dependant_itg in deps.items(): if isinstance(dependant_itg, loader.Integration): + integration_cache[dependant_domain] = dependant_itg needed_requirements.update(dependant_itg.requirements) - resolve_dependencies_tasks = [ - itg.resolve_dependencies() - for itg in integrations_to_process - if not itg.all_dependencies_resolved - ] - if resolve_dependencies_tasks: await asyncio.gather(*resolve_dependencies_tasks) for itg in integrations_to_process: - integration_cache[itg.domain] = itg - for dep in itg.all_dependencies: if dep in domains_to_setup: continue - domains_to_setup.add(dep) to_resolve.add(dep) @@ -670,31 +738,50 @@ async def _async_set_up_integrations( hass.async_create_background_task( requirements.async_load_installed_versions(hass, needed_requirements), "check installed requirements", + eager_start=True, + ) + # Start loading translations for all integrations we are going to set up + # in the background so they are ready when we need them. This avoids a + # lot of waiting for the translation load lock and a thundering herd of + # tasks trying to load the same translations at the same time as each + # integration is loaded. + # + # We do not wait for this since as soon as the task runs it will + # hold the translation load lock and if anything is fast enough to + # wait for the translation load lock, loading will be done by the + # time it gets to it. + hass.async_create_background_task( + translation.async_load_integrations(hass, {*BASE_PLATFORMS, *domains_to_setup}), + "load translations", + eager_start=True, ) - # Initialize recorder - if "recorder" in domains_to_setup: - recorder.async_initialize_recorder(hass) + return domains_to_setup, integration_cache - # Load logging as soon as possible - if logging_domains := domains_to_setup & LOGGING_INTEGRATIONS: - _LOGGER.info("Setting up logging: %s", logging_domains) - await async_setup_multi_components(hass, logging_domains, config) - # Setup frontend - if frontend_domains := domains_to_setup & FRONTEND_INTEGRATIONS: - _LOGGER.info("Setting up frontend: %s", frontend_domains) - await async_setup_multi_components(hass, frontend_domains, config) +async def _async_set_up_integrations( + hass: core.HomeAssistant, config: dict[str, Any] +) -> None: + """Set up all the integrations.""" + setup_started: dict[str, float] = {} + hass.data[DATA_SETUP_STARTED] = setup_started + setup_time: dict[str, timedelta] = hass.data.setdefault(DATA_SETUP_TIME, {}) - # Setup recorder - if recorder_domains := domains_to_setup & RECORDER_INTEGRATIONS: - _LOGGER.info("Setting up recorder: %s", recorder_domains) - await async_setup_multi_components(hass, recorder_domains, config) + watcher = _WatchPendingSetups(hass, setup_started) + watcher.async_start() - # Start up debuggers. Start these first in case they want to wait. - if debuggers := domains_to_setup & DEBUGGER_INTEGRATIONS: - _LOGGER.debug("Setting up debuggers: %s", debuggers) - await async_setup_multi_components(hass, debuggers, config) + domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( + hass, config + ) + + # Initialize recorder + if "recorder" in domains_to_setup: + recorder.async_initialize_recorder(hass) + + pre_stage_domains: dict[str, set[str]] = { + name: domains_to_setup & domain_group + for name, domain_group in SETUP_ORDER.items() + } # calculate what components to setup in what stage stage_1_domains: set[str] = set() @@ -718,14 +805,13 @@ async def _async_set_up_integrations( deps_promotion.update(dep_itg.all_dependencies) - stage_2_domains = ( - domains_to_setup - - logging_domains - - frontend_domains - - recorder_domains - - debuggers - - stage_1_domains - ) + stage_2_domains = domains_to_setup - stage_1_domains + + for name, domain_group in pre_stage_domains.items(): + if domain_group: + stage_2_domains -= domain_group + _LOGGER.info("Setting up %s: %s", name, domain_group) + await async_setup_multi_components(hass, domain_group, config) # Enables after dependencies when setting up stage 1 domains async_set_domains_to_be_loaded(hass, stage_1_domains) @@ -738,7 +824,7 @@ async def _async_set_up_integrations( STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME ): await async_setup_multi_components(hass, stage_1_domains, config) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for stage 1 - moving forward") # Add after dependencies when setting up stage 2 domains @@ -751,7 +837,7 @@ async def _async_set_up_integrations( STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME ): await async_setup_multi_components(hass, stage_2_domains, config) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for stage 2 - moving forward") # Wrap up startup @@ -759,18 +845,12 @@ async def _async_set_up_integrations( try: async with hass.timeout.async_timeout(WRAP_UP_TIMEOUT, cool_down=COOLDOWN_TIME): await hass.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Setup timed out for bootstrap - moving forward") - watch_task.cancel() - async_dispatcher_send(hass, SIGNAL_BOOTSTRAP_INTEGRATIONS, {}) + watcher.async_stop() _LOGGER.debug( "Integration setup times: %s", - { - integration: timedelta.total_seconds() - for integration, timedelta in sorted( - setup_time.items(), key=lambda item: item[1].total_seconds() - ) - }, + dict(sorted(setup_time.items(), key=itemgetter(1))), ) diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index b1d113dad73524..b3fc7872c85756 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -1,7 +1,6 @@ """Adds config flow for AccuWeather.""" from __future__ import annotations -import asyncio from asyncio import timeout from typing import Any @@ -61,7 +60,7 @@ async def async_step_user( longitude=user_input[CONF_LONGITUDE], ) await accuweather.async_get_location() - except (ApiError, ClientConnectorError, asyncio.TimeoutError, ClientError): + except (ApiError, ClientConnectorError, TimeoutError, ClientError): errors["base"] = "cannot_connect" except InvalidApiKeyError: errors[CONF_API_KEY] = "invalid_api_key" diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index b0dd287f428e08..56a11aff200eda 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Rollease Acmeda Automate Pulse Hub.""" from __future__ import annotations -import asyncio from asyncio import timeout from contextlib import suppress from typing import Any @@ -42,7 +41,7 @@ async def async_step_user( } hubs: list[aiopulse.Hub] = [] - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with timeout(5): async for hub in aiopulse.Hub.discover(): if hub.id not in already_configured: diff --git a/homeassistant/components/acomax/__init__.py b/homeassistant/components/acomax/__init__.py new file mode 100644 index 00000000000000..fd8686c3741dc5 --- /dev/null +++ b/homeassistant/components/acomax/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Acomax.""" diff --git a/homeassistant/components/acomax/manifest.json b/homeassistant/components/acomax/manifest.json new file mode 100644 index 00000000000000..9963db68a460dc --- /dev/null +++ b/homeassistant/components/acomax/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "acomax", + "name": "Acomax", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 1f80553031bde6..84d9e29a518898 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -303,7 +303,7 @@ async def async_event_set(): try: async with timeout(10): await self._event.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("Variable %s: Timeout during first update", ads_var) @property diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 870a001a10f3a3..6abd0b18fd4229 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -17,7 +17,8 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -49,6 +50,24 @@ ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" ADVANTAGE_AIR_MYFAN = "autoAA" +HVAC_MODES = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, +] +HVAC_MODES_MYAUTO = HVAC_MODES + [HVACMode.HEAT_COOL] +SUPPORTED_FEATURES = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) +SUPPORTED_FEATURES_MYZONE = SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE +SUPPORTED_FEATURES_MYAUTO = ( + SUPPORTED_FEATURES | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE +) + PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) @@ -84,34 +103,56 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_min_temp = 16 _attr_name = None _enable_turn_on_off_backwards_compatibility = False + _support_preset = ClimateEntityFeature(0) def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) - self._attr_supported_features = ( - ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - self._attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] - # Set supported features and HVAC modes based on current operating mode + self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE] + + # Add "MyTemp" preset if available + if ADVANTAGE_AIR_MYTEMP_ENABLED in self._ac: + self._attr_preset_modes += [ADVANTAGE_AIR_MYTEMP] + self._support_preset = ClimateEntityFeature.PRESET_MODE + + # Add "MyAuto" preset if available + if ADVANTAGE_AIR_MYAUTO_ENABLED in self._ac: + self._attr_preset_modes += [ADVANTAGE_AIR_MYAUTO] + self._support_preset = ClimateEntityFeature.PRESET_MODE + + # Setup attributes based on current preset + self._async_configure_preset() + + def _async_configure_preset(self) -> None: + """Configure attributes based on preset.""" + + # Preset Changes if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto - self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + self._attr_preset_mode = ADVANTAGE_AIR_MYAUTO + self._attr_hvac_modes = HVAC_MODES_MYAUTO + self._attr_supported_features = ( + SUPPORTED_FEATURES_MYAUTO | self._support_preset ) - self._attr_hvac_modes += [HVACMode.HEAT_COOL] - elif not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): + elif self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED): + # MyTemp + self._attr_preset_mode = ADVANTAGE_AIR_MYTEMP + self._attr_hvac_modes = HVAC_MODES + self._attr_supported_features = SUPPORTED_FEATURES | self._support_preset + else: # MyZone - self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE + self._attr_preset_mode = ADVANTAGE_AIR_MYZONE + self._attr_hvac_modes = HVAC_MODES + self._attr_supported_features = ( + SUPPORTED_FEATURES_MYZONE | self._support_preset + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_configure_preset() + super()._handle_coordinator_update() @property def current_temperature(self) -> float | None: @@ -124,11 +165,7 @@ def current_temperature(self) -> float | None: def target_temperature(self) -> float | None: """Return the current target temperature.""" # If the system is in MyZone mode, and a zone is set, return that temperature instead. - if ( - self._myzone - and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) - and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) - ): + if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE: return self._myzone["setTemp"] return self._ac["setTemp"] @@ -169,14 +206,15 @@ async def async_turn_off(self) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC Mode and State.""" if hvac_mode == HVACMode.OFF: - await self.async_update_ac({"state": ADVANTAGE_AIR_STATE_OFF}) - else: - await self.async_update_ac( - { - "state": ADVANTAGE_AIR_STATE_ON, - "mode": HASS_HVAC_MODES.get(hvac_mode), - } - ) + return await self.async_turn_off() + if hvac_mode == HVACMode.HEAT_COOL and self.preset_mode != ADVANTAGE_AIR_MYAUTO: + raise ServiceValidationError("Heat/Cool is not supported in this mode") + await self.async_update_ac( + { + "state": ADVANTAGE_AIR_STATE_ON, + "mode": HASS_HVAC_MODES.get(hvac_mode), + } + ) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" @@ -198,6 +236,16 @@ async def async_set_temperature(self, **kwargs: Any) -> None: } ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + change = {} + if ADVANTAGE_AIR_MYTEMP_ENABLED in self._ac: + change[ADVANTAGE_AIR_MYTEMP_ENABLED] = preset_mode == ADVANTAGE_AIR_MYTEMP + if ADVANTAGE_AIR_MYAUTO_ENABLED in self._ac: + change[ADVANTAGE_AIR_MYAUTO_ENABLED] = preset_mode == ADVANTAGE_AIR_MYAUTO + if change: + await self.async_update_ac(change) + class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): """AdvantageAir MyTemp Zone control.""" diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 5c288b206d0eaa..f019325fb79759 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -31,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: longitude = entry.data[CONF_LONGITUDE] station_updates = entry.options.get(CONF_STATION_UPDATES, True) - options = ConnectionOptions(api_key, station_updates, True) + options = ConnectionOptions(api_key, station_updates) aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index a58faaf6f6b90d..bb73311aa55532 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -21,7 +21,7 @@ OPTIONS_SCHEMA = vol.Schema( { - vol.Required(CONF_STATION_UPDATES): bool, + vol.Required(CONF_STATION_UPDATES, default=True): bool, } ) OPTIONS_FLOW = { @@ -45,7 +45,7 @@ async def async_step_user( await self.async_set_unique_id(f"{latitude}-{longitude}") self._abort_if_unique_id_configured() - options = ConnectionOptions(user_input[CONF_API_KEY], False, True) + options = ConnectionOptions(user_input[CONF_API_KEY], False) aemet = AEMET(aiohttp_client.async_get_clientsession(self.hass), options) try: await aemet.select_coordinates(latitude, longitude) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 6b11e6aa70f68b..9623766b64c2a3 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -20,7 +20,7 @@ AOD_TEMP, AOD_TEMP_MAX, AOD_TEMP_MIN, - AOD_TIMESTAMP, + AOD_TIMESTAMP_UTC, AOD_WIND_DIRECTION, AOD_WIND_SPEED, AOD_WIND_SPEED_MAX, @@ -105,7 +105,7 @@ AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, AOD_TEMP_MAX: ATTR_FORECAST_NATIVE_TEMP, AOD_TEMP_MIN: ATTR_FORECAST_NATIVE_TEMP_LOW, - AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_TIMESTAMP_UTC: ATTR_FORECAST_TIME, AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, }, @@ -114,7 +114,7 @@ AOD_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, AOD_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, AOD_TEMP: ATTR_FORECAST_NATIVE_TEMP, - AOD_TIMESTAMP: ATTR_FORECAST_TIME, + AOD_TIMESTAMP_UTC: ATTR_FORECAST_TIME, AOD_WIND_DIRECTION: ATTR_FORECAST_WIND_BEARING, AOD_WIND_SPEED_MAX: ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, AOD_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py new file mode 100644 index 00000000000000..f49170d9576102 --- /dev/null +++ b/homeassistant/components/aemet/diagnostics.py @@ -0,0 +1,44 @@ +"""Support for the AEMET OpenData diagnostics.""" +from __future__ import annotations + +from typing import Any + +from aemet_opendata.const import AOD_COORDS + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, ENTRY_WEATHER_COORDINATOR +from .coordinator import WeatherUpdateCoordinator + +TO_REDACT_CONFIG = [ + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +] + +TO_REDACT_COORD = [ + AOD_COORDS, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + aemet_entry = hass.data[DOMAIN][config_entry.entry_id] + coordinator: WeatherUpdateCoordinator = aemet_entry[ENTRY_WEATHER_COORDINATOR] + + return { + "api_data": coordinator.aemet.raw_data(), + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), + "coord_data": async_redact_data(coordinator.data, TO_REDACT_COORD), + } diff --git a/homeassistant/components/aemet/manifest.json b/homeassistant/components/aemet/manifest.json index 2bc30860803445..b8a19bcd27af1b 100644 --- a/homeassistant/components/aemet/manifest.json +++ b/homeassistant/components/aemet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aemet", "iot_class": "cloud_polling", "loggers": ["aemet_opendata"], - "requirements": ["AEMET-OpenData==0.4.7"] + "requirements": ["AEMET-OpenData==0.5.1"] } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f51bdcf765a599..75f7f5c0f97e02 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -27,7 +27,7 @@ AOD_TEMP, AOD_TEMP_MAX, AOD_TEMP_MIN, - AOD_TIMESTAMP, + AOD_TIMESTAMP_UTC, AOD_TOWN, AOD_WEATHER, AOD_WIND_DIRECTION, @@ -171,7 +171,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): ), AemetSensorEntityDescription( key=f"forecast-daily-{ATTR_API_FORECAST_TIME}", - keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP_UTC], name="Daily forecast time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -179,7 +179,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): AemetSensorEntityDescription( entity_registry_enabled_default=False, key=f"forecast-hourly-{ATTR_API_FORECAST_TIME}", - keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP], + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_TIMESTAMP_UTC], name="Hourly forecast time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -286,7 +286,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): ), AemetSensorEntityDescription( key=ATTR_API_STATION_TIMESTAMP, - keys=[AOD_STATION, AOD_TIMESTAMP], + keys=[AOD_STATION, AOD_TIMESTAMP_UTC], name="Station timestamp", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, @@ -326,7 +326,7 @@ class AemetSensorEntityDescription(SensorEntityDescription): ), AemetSensorEntityDescription( key=ATTR_API_TOWN_TIMESTAMP, - keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP], + keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_TIMESTAMP_UTC], name="Town timestamp", device_class=SensorDeviceClass.TIMESTAMP, value_fn=dt_util.parse_datetime, diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index d0176cde15daf7..dda5fb7e42626d 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -22,8 +22,6 @@ DEFAULT_NAME: Final = "aftership" UPDATE_TOPIC: Final = f"{DOMAIN}_update" -ICON: Final = "mdi:package-variant-closed" - MIN_TIME_BETWEEN_UPDATES: Final = timedelta(minutes=15) SERVICE_ADD_TRACKING: Final = "add_tracking" diff --git a/homeassistant/components/aftership/icons.json b/homeassistant/components/aftership/icons.json new file mode 100644 index 00000000000000..1222ab0873d68b --- /dev/null +++ b/homeassistant/components/aftership/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "sensor": { + "packages": { + "default": "mdi:package-variant-closed" + } + } + }, + "services": { + "add_tracking": "mdi:package-variant-plus", + "remove_tracking": "mdi:package-variant-minus" + } +} diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index a3b85f2188d50f..055d31fc16d35e 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -35,7 +35,6 @@ CONF_TRACKING_NUMBER, DEFAULT_NAME, DOMAIN, - ICON, MIN_TIME_BETWEEN_UPDATES, REMOVE_TRACKING_SERVICE_SCHEMA, SERVICE_ADD_TRACKING, @@ -135,7 +134,7 @@ class AfterShipSensor(SensorEntity): _attr_attribution = ATTRIBUTION _attr_native_unit_of_measurement: str = "packages" - _attr_icon: str = ICON + _attr_translation_key = "packages" def __init__(self, aftership: AfterShip, name: str) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index d7caaa120fcceb..a494ac0c93f90d 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = entry.data.copy() del new_data[CONF_RADIUS] - entry.version = 2 hass.config_entries.async_update_entry( - entry, data=new_data, options=new_options + entry, data=new_data, options=new_options, version=2 ) _LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index b562e837ff44de..4228fea50d7513 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -23,6 +23,13 @@ _LOGGER = logging.getLogger(__name__) +SERVICE_UUIDS = [ + "b42e1f6e-ade7-11e4-89d3-123b93f75cba", + "b42e4a8e-ade7-11e4-89d3-123b93f75cba", + "b42e1c08-ade7-11e4-89d3-123b93f75cba", + "b42e3882-ade7-11e4-89d3-123b93f75cba", +] + @dataclasses.dataclass class Discovery: @@ -147,6 +154,9 @@ async def async_step_user( if MFCT_ID not in discovery_info.manufacturer_data: continue + if not any(uuid in SERVICE_UUIDS for uuid in discovery_info.service_uuids): + continue + try: device = await self._get_device_data(discovery_info) except AirthingsDeviceUpdateError: diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 1d5babee6d72f0..42cc1e1fade901 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -242,7 +242,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: One geography per config entry if version == 1: - version = entry.version = 2 + version = 2 # Update the config entry to only include the first geography (there is always # guaranteed to be at least one): @@ -255,6 +255,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id=first_id, title=f"Cloud API ({first_id})", data={CONF_API_KEY: entry.data[CONF_API_KEY], **first_geography}, + version=version, ) # For any geographies that remain, create a new config entry for each one: @@ -379,7 +380,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, ) else: - entry.version = version + hass.config_entries.async_update_entry(entry, version=version) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/airvisual/icons.json b/homeassistant/components/airvisual/icons.json new file mode 100644 index 00000000000000..9197830cb63a29 --- /dev/null +++ b/homeassistant/components/airvisual/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "pollutant_level": { + "default": "mdi:gauge" + }, + "pollutant_label": { + "default": "mdi:chemical-weapon" + } + } + } +} diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index 7934d809287686..4da5c395765de4 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["airvisual_pro"], "documentation": "https://www.home-assistant.io/integrations/airvisual", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyairvisual", "pysmb"], diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index ab80e154903e7f..698351887505f3 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -42,7 +42,6 @@ SensorEntityDescription( key=SENSOR_KIND_LEVEL, name="Air pollution level", - icon="mdi:gauge", device_class=SensorDeviceClass.ENUM, options=[ "good", @@ -63,7 +62,6 @@ SensorEntityDescription( key=SENSOR_KIND_POLLUTANT, name="Main pollutant", - icon="mdi:chemical-weapon", device_class=SensorDeviceClass.ENUM, options=["co", "n2", "o3", "p1", "p2", "s2"], translation_key="pollutant_label", diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 6987b3213c1a24..a14215fea6b425 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.7.4"] + "requirements": ["aioairzone==0.7.6"] } diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index 7e787ef4c69f18..697b80942f2fc8 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -24,6 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], + True, ) airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 9f99e49f6501b7..20b747dfae360a 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -7,6 +7,7 @@ from aioairzone_cloud.const import ( AZD_ACTIVE, AZD_AIDOOS, + AZD_AQ_ACTIVE, AZD_ERRORS, AZD_PROBLEMS, AZD_SYSTEMS, @@ -76,6 +77,10 @@ class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): device_class=BinarySensorDeviceClass.RUNNING, key=AZD_ACTIVE, ), + AirzoneBinarySensorEntityDescription( + key=AZD_AQ_ACTIVE, + translation_key="air_quality_active", + ), AirzoneBinarySensorEntityDescription( attributes={ "warnings": AZD_WARNINGS, diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py index 32274d4e8efc18..0d04f78245d283 100644 --- a/homeassistant/components/airzone_cloud/config_flow.py +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -94,6 +94,7 @@ async def async_step_user( ConnectionOptions( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + False, ), ) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index f8b740dc04de17..3b8247d003cd8b 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.8"] + "requirements": ["aioairzone-cloud==0.4.5"] } diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index f45fd248cd5042..965ac24a64fcf2 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -5,6 +5,10 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, + AZD_AQ_INDEX, + AZD_AQ_PM_1, + AZD_AQ_PM_2P5, + AZD_AQ_PM_10, AZD_HUMIDITY, AZD_TEMP, AZD_WEBSERVERS, @@ -20,6 +24,7 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, @@ -58,6 +63,29 @@ ) ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.AQI, + key=AZD_AQ_INDEX, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.PM1, + key=AZD_AQ_PM_1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.PM25, + key=AZD_AQ_PM_2P5, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.PM10, + key=AZD_AQ_PM_10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index 12f155b4486835..fe7c38c83742ab 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -15,5 +15,12 @@ } } } + }, + "entity": { + "binary_sensor": { + "air_quality_active": { + "name": "Air Quality active" + } + } } } diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 3df3c0dbe0a97c..d1c7bc5668b36d 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,5 +1,4 @@ """The aladdin_connect component.""" -import asyncio import logging from typing import Final @@ -29,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await acc.login() - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex: + except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: raise ConfigEntryNotReady("Can not connect to host") from ex except Aladdin.InvalidPasswordError as ex: raise ConfigEntryAuthFailed("Incorrect Password") from ex diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index e5170e9b0a2928..d14b7b7c35e53c 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Aladdin Connect cover integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any @@ -42,7 +41,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: ) try: await acc.login() - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError) as ex: + except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: raise ex except Aladdin.InvalidPasswordError as ex: @@ -81,7 +80,7 @@ async def async_step_reauth_confirm( except InvalidAuth: errors["base"] = "invalid_auth" - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): + except (ClientError, TimeoutError, Aladdin.ConnectionError): errors["base"] = "cannot_connect" else: @@ -117,7 +116,7 @@ async def async_step_user( except InvalidAuth: errors["base"] = "invalid_auth" - except (ClientError, asyncio.TimeoutError, Aladdin.ConnectionError): + except (ClientError, TimeoutError, Aladdin.ConnectionError): errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/alarmdecoder/icons.json b/homeassistant/components/alarmdecoder/icons.json new file mode 100644 index 00000000000000..80835a049c8064 --- /dev/null +++ b/homeassistant/components/alarmdecoder/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "sensor": { + "alarm_panel_display": { + "default": "mdi:alarm-check" + } + } + }, + "services": { + "alarm_keypress": "mdi:dialpad", + "alarm_toggle_chime": "mdi:abc" + } +} diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index f0ffc7e7158236..1598171649bf2b 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -20,7 +20,7 @@ async def async_setup_entry( class AlarmDecoderSensor(SensorEntity): """Representation of an AlarmDecoder keypad.""" - _attr_icon = "mdi:alarm-check" + _attr_translation_key = "alarm_panel_display" _attr_name = "Alarm Panel Display" _attr_should_poll = False diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 527e51b5390f19..10a7be4967ef67 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -122,7 +122,7 @@ async def _async_request_new_token(self, lwa_params: dict[str, str]) -> str | No allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout calling LWA to get auth token") return None diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index a1ab1d77081875..02aaed25742ca4 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -29,12 +29,20 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._enable_proactive_mode_lock = asyncio.Lock() + self._on_deinitialize: list[CALLBACK_TYPE] = [] async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = AlexaConfigStore(self.hass) await self._store.async_load() + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() + @property def supports_auth(self) -> bool: """Return if config supports auth.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 20e66dfa08471d..3ad863747e5e36 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -1,7 +1,6 @@ """Alexa state report code.""" from __future__ import annotations -import asyncio from asyncio import timeout from http import HTTPStatus import json @@ -375,7 +374,7 @@ async def async_send_changereport_message( allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id) return @@ -531,7 +530,7 @@ async def async_send_doorbell_event_message( allow_redirects=True, ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout sending report to Alexa for %s", alexa_entity.entity_id) return diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json new file mode 100644 index 00000000000000..b9716387b53f53 --- /dev/null +++ b/homeassistant/components/amberelectric/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "general": { + "default": "mdi:transmission-tower" + }, + "controlled_load": { + "default": "mdi:clock-outline" + }, + "feed_in": { + "default": "mdi:solar-power" + }, + "renewables": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 97ecc1036618fe..547b51a0f67817 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -27,12 +27,6 @@ from .const import ATTRIBUTION, DOMAIN from .coordinator import AmberUpdateCoordinator, normalize_descriptor -ICONS = { - "general": "mdi:transmission-tower", - "controlled_load": "mdi:clock-outline", - "feed_in": "mdi:solar-power", -} - UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" @@ -219,7 +213,7 @@ async def async_setup_entry( name=f"{entry.title} - {friendly_channel_type(channel_type)} Price", native_unit_of_measurement=UNIT, state_class=SensorStateClass.MEASUREMENT, - icon=ICONS[channel_type], + translation_key=channel_type, ) entities.append(AmberPriceSensor(coordinator, description, channel_type)) @@ -230,7 +224,7 @@ async def async_setup_entry( f"{entry.title} - {friendly_channel_type(channel_type)} Price" " Descriptor" ), - icon=ICONS[channel_type], + translation_key=channel_type, ) entities.append( AmberPriceDescriptorSensor(coordinator, description, channel_type) @@ -242,7 +236,7 @@ async def async_setup_entry( name=f"{entry.title} - {friendly_channel_type(channel_type)} Forecast", native_unit_of_measurement=UNIT, state_class=SensorStateClass.MEASUREMENT, - icon=ICONS[channel_type], + translation_key=channel_type, ) entities.append(AmberForecastSensor(coordinator, description, channel_type)) @@ -251,7 +245,7 @@ async def async_setup_entry( name=f"{entry.title} - Renewables", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:solar-power", + translation_key="renewables", ) entities.append(AmberGridSensor(coordinator, renewables_description)) diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py index 240c9780cee9e5..7ed9deec8989b6 100644 --- a/homeassistant/components/ambiclimate/__init__.py +++ b/homeassistant/components/ambiclimate/__init__.py @@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from . import config_flow @@ -41,5 +41,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ambiclimate from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/ambiclimate", + }, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/ambiclimate/icons.json b/homeassistant/components/ambiclimate/icons.json new file mode 100644 index 00000000000000..cce21c18c20d1f --- /dev/null +++ b/homeassistant/components/ambiclimate/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "set_comfort_mode": "mdi:auto-mode", + "send_comfort_feedback": "mdi:thermometer-checked", + "set_temperature_mode": "mdi:thermometer" + } +} diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json index 2b55f7bebb60ad..15a1a4e1f35d48 100644 --- a/homeassistant/components/ambiclimate/strings.json +++ b/homeassistant/components/ambiclimate/strings.json @@ -19,6 +19,12 @@ "access_token": "Unknown error generating an access token." } }, + "issues": { + "integration_removed": { + "title": "The Ambiclimate integration has been deprecated and will be removed", + "description": "All Ambiclimate services will be terminated, effective March 31, 2024, as Ambi Labs winds down business operations, and the Ambiclimate integration will be removed from Home Assistant.\n\nTo resolve this issue, please remove the integration entries from your Home Assistant setup. [Click here to see your existing Logi Circle integration entries]({entries})." + } + }, "services": { "set_comfort_mode": { "name": "Set comfort mode", @@ -40,7 +46,7 @@ }, "value": { "name": "Comfort value", - "description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing\n." + "description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing." } } }, diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 1718b559fdeba2..7dd6b455e73f97 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -111,7 +111,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: en_reg = er.async_get(hass) en_reg.async_clear_config_entry(entry.entry_id) - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) LOGGER.info("Migration to version %s successful", version) diff --git a/homeassistant/components/amp_motorization/__init__.py b/homeassistant/components/amp_motorization/__init__.py new file mode 100644 index 00000000000000..5f92880b963ea2 --- /dev/null +++ b/homeassistant/components/amp_motorization/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AMP motorization.""" diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 1c81eacd14aba8..d2c0cec20ebb42 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -173,6 +173,7 @@ async def save_preferences(self, preferences: dict) -> None: async def send_analytics(self, _: datetime | None = None) -> None: """Send analytics.""" + hass = self.hass supervisor_info = None operating_system_info: dict[str, Any] = {} @@ -185,10 +186,10 @@ async def send_analytics(self, _: datetime | None = None) -> None: await self._store.async_save(dataclass_asdict(self._data)) if self.supervisor: - supervisor_info = hassio.get_supervisor_info(self.hass) - operating_system_info = hassio.get_os_info(self.hass) or {} + supervisor_info = hassio.get_supervisor_info(hass) + operating_system_info = hassio.get_os_info(hass) or {} - system_info = await async_get_system_info(self.hass) + system_info = await async_get_system_info(hass) integrations = [] custom_integrations = [] addons = [] @@ -214,10 +215,10 @@ async def send_analytics(self, _: datetime | None = None) -> None: if self.preferences.get(ATTR_USAGE, False) or self.preferences.get( ATTR_STATISTICS, False ): - ent_reg = er.async_get(self.hass) + ent_reg = er.async_get(hass) try: - yaml_configuration = await conf_util.async_hass_config_yaml(self.hass) + yaml_configuration = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: LOGGER.error(err) return @@ -229,8 +230,8 @@ async def send_analytics(self, _: datetime | None = None) -> None: if not entity.disabled } - domains = async_get_loaded_integrations(self.hass) - configured_integrations = await async_get_integrations(self.hass, domains) + domains = async_get_loaded_integrations(hass) + configured_integrations = await async_get_integrations(hass, domains) enabled_domains = set(configured_integrations) for integration in configured_integrations.values(): @@ -261,7 +262,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: if supervisor_info is not None: installed_addons = await asyncio.gather( *( - hassio.async_get_addon_info(self.hass, addon[ATTR_SLUG]) + hassio.async_get_addon_info(hass, addon[ATTR_SLUG]) for addon in supervisor_info[ATTR_ADDONS] ) ) @@ -276,7 +277,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: ) if self.preferences.get(ATTR_USAGE, False): - payload[ATTR_CERTIFICATE] = self.hass.http.ssl_certificate is not None + payload[ATTR_CERTIFICATE] = hass.http.ssl_certificate is not None payload[ATTR_INTEGRATIONS] = integrations payload[ATTR_CUSTOM_INTEGRATIONS] = custom_integrations if supervisor_info is not None: @@ -284,11 +285,11 @@ async def send_analytics(self, _: datetime | None = None) -> None: if ENERGY_DOMAIN in enabled_domains: payload[ATTR_ENERGY] = { - ATTR_CONFIGURED: await energy_is_configured(self.hass) + ATTR_CONFIGURED: await energy_is_configured(hass) } if RECORDER_DOMAIN in enabled_domains: - instance = get_recorder_instance(self.hass) + instance = get_recorder_instance(hass) engine = instance.database_engine if engine and engine.version is not None: payload[ATTR_RECORDER] = { @@ -297,9 +298,9 @@ async def send_analytics(self, _: datetime | None = None) -> None: } if self.preferences.get(ATTR_STATISTICS, False): - payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all()) - payload[ATTR_AUTOMATION_COUNT] = len( - self.hass.states.async_all(AUTOMATION_DOMAIN) + payload[ATTR_STATE_COUNT] = hass.states.async_entity_ids_count() + payload[ATTR_AUTOMATION_COUNT] = hass.states.async_entity_ids_count( + AUTOMATION_DOMAIN ) payload[ATTR_INTEGRATION_COUNT] = len(integrations) if supervisor_info is not None: @@ -307,7 +308,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: payload[ATTR_USER_COUNT] = len( [ user - for user in await self.hass.auth.async_get_users() + for user in await hass.auth.async_get_users() if not user.system_generated ] ) @@ -329,7 +330,7 @@ async def send_analytics(self, _: datetime | None = None) -> None: response.status, self.endpoint, ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL) except aiohttp.ClientError as err: LOGGER.error( diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 955c4a813f40c3..6ab6898ec2704a 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -5,6 +5,7 @@ "codeowners": ["@home-assistant/core", "@ludeeus"], "dependencies": ["api", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/analytics", + "import_executor": true, "integration_type": "system", "iot_class": "cloud_push", "quality_scale": "internal" diff --git a/homeassistant/components/analytics_insights/manifest.json b/homeassistant/components/analytics_insights/manifest.json index d33bb23b1b78b6..b55e08a8141fde 100644 --- a/homeassistant/components/analytics_insights/manifest.json +++ b/homeassistant/components/analytics_insights/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/analytics_insights", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_homeassistant_analytics"], diff --git a/homeassistant/components/android_ip_webcam/icons.json b/homeassistant/components/android_ip_webcam/icons.json new file mode 100644 index 00000000000000..9fa537705e2216 --- /dev/null +++ b/homeassistant/components/android_ip_webcam/icons.json @@ -0,0 +1,62 @@ +{ + "entity": { + "sensor": { + "audio_connections": { + "default": "mdi:speaker" + }, + "battery_temperature": { + "default": "mdi:thermometer" + }, + "light": { + "default": "mdi:flashlight" + }, + "motion": { + "default": "mdi:run" + }, + "pressure": { + "default": "mdi:gauge" + }, + "proximity": { + "default": "mdi:map-marker-radius" + }, + "sound": { + "default": "mdi:speaker" + }, + "video_connections": { + "default": "mdi:eye" + } + }, + "switch": { + "exposure_lock": { + "default": "mdi:camera" + }, + "ffc": { + "default": "mdi:camera-front-variant" + }, + "focus": { + "default": "mdi:image-filter-center-focus" + }, + "gps_active": { + "default": "mdi:crosshairs-gps" + }, + "motion_detect": { + "default": "mdi:flash" + }, + "night_vision": { + "default": "mdi:weather-night" + }, + "overlay": { + "default": "mdi:monitor" + }, + "torch": { + "default": "mdi:white-balance-sunny" + }, + "whitebalance_lock": { + "default": "mdi:white-balance-auto" + }, + "video_recording": { + "default": "mdi:record-rec" + } + } + } +} diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index d7a821d956aed4..e55112b7259777 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -42,8 +42,8 @@ class AndroidIPWebcamSensorEntityDescription( SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = ( AndroidIPWebcamSensorEntityDescription( key="audio_connections", + translation_key="audio_connections", name="Audio connections", - icon="mdi:speaker", state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda ipcam: ipcam.status_data.get("audio_connections"), @@ -59,8 +59,8 @@ class AndroidIPWebcamSensorEntityDescription( ), AndroidIPWebcamSensorEntityDescription( key="battery_temp", + translation_key="battery_temperature", name="Battery temperature", - icon="mdi:thermometer", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda ipcam: ipcam.get_sensor_value("battery_temp"), @@ -76,48 +76,48 @@ class AndroidIPWebcamSensorEntityDescription( ), AndroidIPWebcamSensorEntityDescription( key="light", + translation_key="light", name="Light level", - icon="mdi:flashlight", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ipcam: ipcam.get_sensor_value("light"), unit_fn=lambda ipcam: ipcam.get_sensor_unit("light"), ), AndroidIPWebcamSensorEntityDescription( key="motion", + translation_key="motion", name="Motion", - icon="mdi:run", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ipcam: ipcam.get_sensor_value("motion"), unit_fn=lambda ipcam: ipcam.get_sensor_unit("motion"), ), AndroidIPWebcamSensorEntityDescription( key="pressure", + translation_key="pressure", name="Pressure", - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ipcam: ipcam.get_sensor_value("pressure"), unit_fn=lambda ipcam: ipcam.get_sensor_unit("pressure"), ), AndroidIPWebcamSensorEntityDescription( key="proximity", + translation_key="proximity", name="Proximity", - icon="mdi:map-marker-radius", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ipcam: ipcam.get_sensor_value("proximity"), unit_fn=lambda ipcam: ipcam.get_sensor_unit("proximity"), ), AndroidIPWebcamSensorEntityDescription( key="sound", + translation_key="sound", name="Sound", - icon="mdi:speaker", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda ipcam: ipcam.get_sensor_value("sound"), unit_fn=lambda ipcam: ipcam.get_sensor_unit("sound"), ), AndroidIPWebcamSensorEntityDescription( key="video_connections", + translation_key="video_connections", name="Video connections", - icon="mdi:eye", state_class=SensorStateClass.TOTAL, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda ipcam: ipcam.status_data.get("video_connections"), diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index bae847390798d3..d2a40cb619a68e 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -36,80 +36,80 @@ class AndroidIPWebcamSwitchEntityDescription( SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = ( AndroidIPWebcamSwitchEntityDescription( key="exposure_lock", + translation_key="exposure_lock", name="Exposure lock", - icon="mdi:camera", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("exposure_lock", True), off_func=lambda ipcam: ipcam.change_setting("exposure_lock", False), ), AndroidIPWebcamSwitchEntityDescription( key="ffc", + translation_key="ffc", name="Front-facing camera", - icon="mdi:camera-front-variant", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("ffc", True), off_func=lambda ipcam: ipcam.change_setting("ffc", False), ), AndroidIPWebcamSwitchEntityDescription( key="focus", + translation_key="focus", name="Focus", - icon="mdi:image-filter-center-focus", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.focus(activate=True), off_func=lambda ipcam: ipcam.focus(activate=False), ), AndroidIPWebcamSwitchEntityDescription( key="gps_active", + translation_key="gps_active", name="GPS active", - icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("gps_active", True), off_func=lambda ipcam: ipcam.change_setting("gps_active", False), ), AndroidIPWebcamSwitchEntityDescription( key="motion_detect", + translation_key="motion_detect", name="Motion detection", - icon="mdi:flash", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("motion_detect", True), off_func=lambda ipcam: ipcam.change_setting("motion_detect", False), ), AndroidIPWebcamSwitchEntityDescription( key="night_vision", + translation_key="night_vision", name="Night vision", - icon="mdi:weather-night", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("night_vision", True), off_func=lambda ipcam: ipcam.change_setting("night_vision", False), ), AndroidIPWebcamSwitchEntityDescription( key="overlay", + translation_key="overlay", name="Overlay", - icon="mdi:monitor", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("overlay", True), off_func=lambda ipcam: ipcam.change_setting("overlay", False), ), AndroidIPWebcamSwitchEntityDescription( key="torch", + translation_key="torch", name="Torch", - icon="mdi:white-balance-sunny", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.torch(activate=True), off_func=lambda ipcam: ipcam.torch(activate=False), ), AndroidIPWebcamSwitchEntityDescription( key="whitebalance_lock", + translation_key="whitebalance_lock", name="White balance lock", - icon="mdi:white-balance-auto", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.change_setting("whitebalance_lock", True), off_func=lambda ipcam: ipcam.change_setting("whitebalance_lock", False), ), AndroidIPWebcamSwitchEntityDescription( key="video_recording", + translation_key="video_recording", name="Video recording", - icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, on_func=lambda ipcam: ipcam.record(record=True), off_func=lambda ipcam: ipcam.record(record=False), diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py new file mode 100644 index 00000000000000..e9cbd435d9bc05 --- /dev/null +++ b/homeassistant/components/androidtv/entity.py @@ -0,0 +1,145 @@ +"""Base AndroidTV Entity.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +import functools +import logging +from typing import Any, Concatenate, ParamSpec, TypeVar + +from androidtv.exceptions import LockNotAcquiredException +from androidtv.setup_async import AndroidTVAsync, FireTVAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_CONNECTIONS, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_HOST, + CONF_NAME, +) +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity + +from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac +from .const import DEVICE_ANDROIDTV, DOMAIN + +PREFIX_ANDROIDTV = "Android TV" +PREFIX_FIRETV = "Fire TV" + +_LOGGER = logging.getLogger(__name__) + +_ADBDeviceT = TypeVar("_ADBDeviceT", bound="AndroidTVEntity") +_R = TypeVar("_R") +_P = ParamSpec("_P") + +_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]] +_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]] + + +def adb_decorator( + override_available: bool = False, +) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]: + """Wrap ADB methods and catch exceptions. + + Allows for overriding the available status of the ADB connection via the + `override_available` parameter. + """ + + def _adb_decorator( + func: _FuncType[_ADBDeviceT, _P, _R], + ) -> _ReturnFuncType[_ADBDeviceT, _P, _R]: + """Wrap the provided ADB method and catch exceptions.""" + + @functools.wraps(func) + async def _adb_exception_catcher( + self: _ADBDeviceT, *args: _P.args, **kwargs: _P.kwargs + ) -> _R | None: + """Call an ADB-related method and catch exceptions.""" + if not self.available and not override_available: + return None + + try: + return await func(self, *args, **kwargs) + except LockNotAcquiredException: + # If the ADB lock could not be acquired, skip this command + _LOGGER.info( + ( + "ADB command %s not executed because the connection is" + " currently in use" + ), + func.__name__, + ) + return None + except self.exceptions as err: + _LOGGER.error( + ( + "Failed to execute an ADB command. ADB connection re-" + "establishing attempt in the next update. Error: %s" + ), + err, + ) + await self.aftv.adb_close() + # pylint: disable-next=protected-access + self._attr_available = False + return None + except Exception: + # An unforeseen exception occurred. Close the ADB connection so that + # it doesn't happen over and over again, then raise the exception. + await self.aftv.adb_close() + # pylint: disable-next=protected-access + self._attr_available = False + raise + + return _adb_exception_catcher + + return _adb_decorator + + +class AndroidTVEntity(Entity): + """Defines a base AndroidTV entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + aftv: AndroidTVAsync | FireTVAsync, + entry: ConfigEntry, + entry_data: dict[str, Any], + ) -> None: + """Initialize the AndroidTV base entity.""" + self.aftv = aftv + self._attr_unique_id = entry.unique_id + self._entry_data = entry_data + + device_class = aftv.DEVICE_CLASS + device_type = ( + PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV + ) + # CONF_NAME may be present in entry.data for configuration imported from YAML + device_name = entry.data.get( + CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}" + ) + info = aftv.device_properties + model = info.get(ATTR_MODEL) + self._attr_device_info = DeviceInfo( + model=f"{model} ({device_type})" if model else device_type, + name=device_name, + ) + if self.unique_id: + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} + if manufacturer := info.get(ATTR_MANUFACTURER): + self._attr_device_info[ATTR_MANUFACTURER] = manufacturer + if sw_version := info.get(ATTR_SW_VERSION): + self._attr_device_info[ATTR_SW_VERSION] = sw_version + if mac := get_androidtv_mac(info): + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} + + # ADB exceptions to catch + if not aftv.adb_server_ip: + # Using "adb_shell" (Python ADB implementation) + self.exceptions = ADB_PYTHON_EXCEPTIONS + else: + # Using "pure-python-adb" (communicate with ADB server) + self.exceptions = ADB_TCP_EXCEPTIONS diff --git a/homeassistant/components/androidtv/icons.json b/homeassistant/components/androidtv/icons.json new file mode 100644 index 00000000000000..0127d60a72e528 --- /dev/null +++ b/homeassistant/components/androidtv/icons.json @@ -0,0 +1,8 @@ +{ + "services": { + "adb_command": "mdi:console", + "download": "mdi:download", + "upload": "mdi:upload", + "learn_sendevent": "mdi:remote" + } +} diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index bd058ac769e133..5e97396b369a23 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,15 +1,12 @@ """Support for functionality to interact with Android / Fire TV devices.""" from __future__ import annotations -from collections.abc import Awaitable, Callable, Coroutine from datetime import timedelta -import functools import hashlib import logging -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any from androidtv.constants import APPS, KEYS -from androidtv.exceptions import LockNotAcquiredException from androidtv.setup_async import AndroidTVAsync, FireTVAsync import voluptuous as vol @@ -21,23 +18,13 @@ MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_COMMAND, - ATTR_CONNECTIONS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SW_VERSION, - CONF_HOST, - CONF_NAME, -) +from homeassistant.const import ATTR_COMMAND from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle -from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac from .const import ( ANDROID_DEV, ANDROID_DEV_OPT, @@ -54,10 +41,7 @@ DOMAIN, SIGNAL_CONFIG_ENTITY, ) - -_ADBDeviceT = TypeVar("_ADBDeviceT", bound="ADBDevice") -_R = TypeVar("_R") -_P = ParamSpec("_P") +from .entity import AndroidTVEntity, adb_decorator _LOGGER = logging.getLogger(__name__) @@ -73,9 +57,6 @@ SERVICE_LEARN_SENDEVENT = "learn_sendevent" SERVICE_UPLOAD = "upload" -PREFIX_ANDROIDTV = "Android TV" -PREFIX_FIRETV = "Fire TV" - # Translate from `AndroidTV` / `FireTV` reported state to HA state. ANDROIDTV_STATES = { "off": MediaPlayerState.OFF, @@ -92,25 +73,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Android Debug Bridge entity.""" - aftv: AndroidTVAsync | FireTVAsync = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] - device_class = aftv.DEVICE_CLASS - device_type = ( - PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV - ) - # CONF_NAME may be present in entry.data for configuration imported from YAML - device_name: str = entry.data.get( - CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}" - ) - - device_args = [ - aftv, - device_name, - device_type, - entry.unique_id, - entry.entry_id, - hass.data[DOMAIN][entry.entry_id], - ] + entry_data = hass.data[DOMAIN][entry.entry_id] + aftv: AndroidTVAsync | FireTVAsync = entry_data[ANDROID_DEV] + device_class = aftv.DEVICE_CLASS + device_args = [aftv, entry, entry_data] async_add_entities( [ AndroidTVDevice(*device_args) @@ -146,108 +113,25 @@ async def async_setup_entry( ) -_FuncType = Callable[Concatenate[_ADBDeviceT, _P], Awaitable[_R]] -_ReturnFuncType = Callable[Concatenate[_ADBDeviceT, _P], Coroutine[Any, Any, _R | None]] - - -def adb_decorator( - override_available: bool = False, -) -> Callable[[_FuncType[_ADBDeviceT, _P, _R]], _ReturnFuncType[_ADBDeviceT, _P, _R]]: - """Wrap ADB methods and catch exceptions. - - Allows for overriding the available status of the ADB connection via the - `override_available` parameter. - """ - - def _adb_decorator( - func: _FuncType[_ADBDeviceT, _P, _R], - ) -> _ReturnFuncType[_ADBDeviceT, _P, _R]: - """Wrap the provided ADB method and catch exceptions.""" - - @functools.wraps(func) - async def _adb_exception_catcher( - self: _ADBDeviceT, *args: _P.args, **kwargs: _P.kwargs - ) -> _R | None: - """Call an ADB-related method and catch exceptions.""" - if not self.available and not override_available: - return None - - try: - return await func(self, *args, **kwargs) - except LockNotAcquiredException: - # If the ADB lock could not be acquired, skip this command - _LOGGER.info( - ( - "ADB command %s not executed because the connection is" - " currently in use" - ), - func.__name__, - ) - return None - except self.exceptions as err: - _LOGGER.error( - ( - "Failed to execute an ADB command. ADB connection re-" - "establishing attempt in the next update. Error: %s" - ), - err, - ) - await self.aftv.adb_close() - # pylint: disable-next=protected-access - self._attr_available = False - return None - except Exception: - # An unforeseen exception occurred. Close the ADB connection so that - # it doesn't happen over and over again, then raise the exception. - await self.aftv.adb_close() - # pylint: disable-next=protected-access - self._attr_available = False - raise - - return _adb_exception_catcher - - return _adb_decorator - - -class ADBDevice(MediaPlayerEntity): +class ADBDevice(AndroidTVEntity, MediaPlayerEntity): """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV - _attr_has_entity_name = True _attr_name = None def __init__( self, aftv: AndroidTVAsync | FireTVAsync, - name: str, - dev_type: str, - unique_id: str, - entry_id: str, + entry: ConfigEntry, entry_data: dict[str, Any], ) -> None: """Initialize the Android / Fire TV device.""" - self.aftv = aftv - self._attr_unique_id = unique_id - self._entry_id = entry_id - self._entry_data = entry_data + super().__init__(aftv, entry, entry_data) + self._entry_id = entry.entry_id self._media_image: tuple[bytes | None, str | None] = None, None self._attr_media_image_hash = None - info = aftv.device_properties - model = info.get(ATTR_MODEL) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - model=f"{model} ({dev_type})" if model else dev_type, - name=name, - ) - if manufacturer := info.get(ATTR_MANUFACTURER): - self._attr_device_info[ATTR_MANUFACTURER] = manufacturer - if sw_version := info.get(ATTR_SW_VERSION): - self._attr_device_info[ATTR_SW_VERSION] = sw_version - if mac := get_androidtv_mac(info): - self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} - self._app_id_to_name: dict[str, str] = {} self._app_name_to_id: dict[str, str] = {} self._get_sources = DEFAULT_GET_SOURCES @@ -256,14 +140,6 @@ def __init__( self.turn_on_command: str | None = None self.turn_off_command: str | None = None - # ADB exceptions to catch - if not aftv.adb_server_ip: - # Using "adb_shell" (Python ADB implementation) - self.exceptions = ADB_PYTHON_EXCEPTIONS - else: - # Using "pure-python-adb" (communicate with ADB server) - self.exceptions = ADB_TCP_EXCEPTIONS - # Property attributes self._attr_extra_state_attributes = { ATTR_ADB_RESPONSE: None, diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index c78321589a9d3b..9e99a93efa6079 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -1,7 +1,6 @@ """The Android TV Remote integration.""" from __future__ import annotations -import asyncio from asyncio import timeout import logging @@ -50,7 +49,7 @@ def is_available_updated(is_available: bool) -> None: except InvalidAuth as exc: # The Android TV is hard reset or the certificate and key files were deleted. raise ConfigEntryAuthFailed from exc - except (CannotConnect, ConnectionClosed, asyncio.TimeoutError) as exc: + except (CannotConnect, ConnectionClosed, TimeoutError) as exc: # The Android TV is network unreachable. Raise exception and let Home Assistant retry # later. If device gets a new IP address the zeroconf flow will update the config. raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index f45dee34afef67..02197a6168194a 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tronikos", "@Drafteed"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", + "import_executor": true, "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 0a9edeb22691a5..a4982b2e9e8461 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -53,7 +53,6 @@ class AnthemAVR(MediaPlayerEntity): _attr_name = None _attr_should_poll = False _attr_device_class = MediaPlayerDeviceClass.RECEIVER - _attr_icon = "mdi:audio-video" _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE diff --git a/homeassistant/components/aosmith/icons.json b/homeassistant/components/aosmith/icons.json new file mode 100644 index 00000000000000..e31a68464cec7d --- /dev/null +++ b/homeassistant/components/aosmith/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "hot_water_availability": { + "default": "mdi:water-thermometer" + } + } + } +} diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index e4a99a340de753..e33c388af8b7e9 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -33,7 +33,6 @@ class AOSmithStatusSensorEntityDescription(SensorEntityDescription): AOSmithStatusSensorEntityDescription( key="hot_water_availability", translation_key="hot_water_availability", - icon="mdi:water-thermometer", device_class=SensorDeviceClass.ENUM, options=["low", "medium", "high"], value_fn=lambda device: HOT_WATER_STATUS_MAP.get( diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index 76e88689ca50c7..d200df743f1190 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -19,7 +19,7 @@ _DESCRIPTION = BinarySensorEntityDescription( key="statflag", name="UPS Online Status", - icon="mdi:heart", + translation_key="online_status", ) # The bit in STATFLAG that indicates the online status of the APC UPS. _VALUE_ONLINE_MASK: Final = 0b1000 diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 99c78fd5d337cf..25a1ccf7e0239b 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -1,7 +1,6 @@ """Config flow for APCUPSd integration.""" from __future__ import annotations -import asyncio from typing import Any import voluptuous as vol @@ -54,7 +53,7 @@ async def async_step_user( coordinator = APCUPSdCoordinator(self.hass, host, port) await coordinator.async_request_refresh() - if isinstance(coordinator.last_exception, (UpdateFailed, asyncio.TimeoutError)): + if isinstance(coordinator.last_exception, (UpdateFailed, TimeoutError)): errors = {"base": "cannot_connect"} return self.async_show_form( step_id="user", data_schema=_SCHEMA, errors=errors diff --git a/homeassistant/components/apcupsd/icons.json b/homeassistant/components/apcupsd/icons.json new file mode 100644 index 00000000000000..886cf713c5faf6 --- /dev/null +++ b/homeassistant/components/apcupsd/icons.json @@ -0,0 +1,155 @@ +{ + "entity": { + "binary_sensor": { + "online_status": { + "default": "mdi:heart" + } + }, + "sensor": { + "alarm_delay": { + "default": "mdi:alarm" + }, + "apc_status": { + "default": "mdi:information-outline" + }, + "apc_model": { + "default": "mdi:information-outline" + }, + "bad_batteries": { + "default": "mdi:information-outline" + }, + "battery_replacement_date": { + "default": "mdi:calendar-clock" + }, + "battery_status": { + "default": "mdi:information-outline" + }, + "cable_type": { + "default": "mdi:ethernet-cable" + }, + "total_time_on_battery": { + "default": "mdi:timer-outline" + }, + "date": { + "default": "mdi:calendar-clock" + }, + "dip_switch_settings": { + "default": "mdi:information-outline" + }, + "low_battery_signal": { + "default": "mdi:clock-alert" + }, + "driver": { + "default": "mdi:information-outline" + }, + "shutdown_delay": { + "default": "mdi:timer-outline" + }, + "wake_delay": { + "default": "mdi:timer-outline" + }, + "date_and_time": { + "default": "mdi:calendar-clock" + }, + "external_batteries": { + "default": "mdi:information-outline" + }, + "firmware_version": { + "default": "mdi:information-outline" + }, + "hostname": { + "default": "mdi:information-outline" + }, + "last_self_test": { + "default": "mdi:calendar-clock" + }, + "last_transfer": { + "default": "mdi:transfer" + }, + "line_failure": { + "default": "mdi:information-outline" + }, + "load_capacity": { + "default": "mdi:gauge" + }, + "apparent_power": { + "default": "mdi:gauge" + }, + "manufacture_date": { + "default": "mdi:calendar" + }, + "master_update": { + "default": "mdi:information-outline" + }, + "max_time": { + "default": "mdi:timer-off-outline" + }, + "max_battery_charge": { + "default": "mdi:battery-alert" + }, + "min_time": { + "default": "mdi:timer-outline" + }, + "model": { + "default": "mdi:information-outline" + }, + "transfer_count": { + "default": "mdi:counter" + }, + "register_1_fault": { + "default": "mdi:information-outline" + }, + "register_2_fault": { + "default": "mdi:information-outline" + }, + "register_3_fault": { + "default": "mdi:information-outline" + }, + "restore_capacity": { + "default": "mdi:battery-alert" + }, + "self_test_result": { + "default": "mdi:information-outline" + }, + "sensitivity": { + "default": "mdi:information-outline" + }, + "serial_number": { + "default": "mdi:information-outline" + }, + "startup_time": { + "default": "mdi:calendar-clock" + }, + "online_status": { + "default": "mdi:information-outline" + }, + "status": { + "default": "mdi:information-outline" + }, + "self_test_interval": { + "default": "mdi:information-outline" + }, + "time_left": { + "default": "mdi:clock-alert" + }, + "time_on_battery": { + "default": "mdi:timer-outline" + }, + "ups_mode": { + "default": "mdi:information-outline" + }, + "ups_name": { + "default": "mdi:information-outline" + }, + "version": { + "default": "mdi:information-outline" + }, + "transfer_from_battery": { + "default": "mdi:transfer" + }, + "transfer_to_battery": { + "default": "mdi:transfer" + } + } + } +} diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 71dc9940b72595..4a2261f0b30c2d 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -31,43 +31,42 @@ SENSORS: dict[str, SensorEntityDescription] = { "alarmdel": SensorEntityDescription( key="alarmdel", + translation_key="alarm_delay", name="UPS Alarm Delay", - icon="mdi:alarm", ), "ambtemp": SensorEntityDescription( key="ambtemp", name="UPS Ambient Temperature", - icon="mdi:thermometer", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), "apc": SensorEntityDescription( key="apc", + translation_key="apc_status", name="UPS Status Data", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "apcmodel": SensorEntityDescription( key="apcmodel", + translation_key="apc_model", name="UPS Model", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "badbatts": SensorEntityDescription( key="badbatts", + translation_key="bad_batteries", name="UPS Bad Batteries", - icon="mdi:information-outline", ), "battdate": SensorEntityDescription( key="battdate", + translation_key="battery_replacement_date", name="UPS Battery Replaced", - icon="mdi:calendar-clock", ), "battstat": SensorEntityDescription( key="battstat", + translation_key="battery_status", name="UPS Battery Status", - icon="mdi:information-outline", ), "battv": SensorEntityDescription( key="battv", @@ -80,69 +79,68 @@ key="bcharge", name="UPS Battery", native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery", device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), "cable": SensorEntityDescription( key="cable", + translation_key="cable_type", name="UPS Cable Type", - icon="mdi:ethernet-cable", entity_registry_enabled_default=False, ), "cumonbatt": SensorEntityDescription( key="cumonbatt", + translation_key="total_time_on_battery", name="UPS Total Time on Battery", - icon="mdi:timer-outline", state_class=SensorStateClass.TOTAL_INCREASING, ), "date": SensorEntityDescription( key="date", + translation_key="date", name="UPS Status Date", - icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), "dipsw": SensorEntityDescription( key="dipsw", + translation_key="dip_switch_settings", name="UPS Dip Switch Settings", - icon="mdi:information-outline", ), "dlowbatt": SensorEntityDescription( key="dlowbatt", + translation_key="low_battery_signal", name="UPS Low Battery Signal", - icon="mdi:clock-alert", ), "driver": SensorEntityDescription( key="driver", + translation_key="driver", name="UPS Driver", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "dshutd": SensorEntityDescription( key="dshutd", + translation_key="shutdown_delay", name="UPS Shutdown Delay", - icon="mdi:timer-outline", ), "dwake": SensorEntityDescription( key="dwake", + translation_key="wake_delay", name="UPS Wake Delay", - icon="mdi:timer-outline", ), "end apc": SensorEntityDescription( key="end apc", + translation_key="date_and_time", name="UPS Date and Time", - icon="mdi:calendar-clock", entity_registry_enabled_default=False, ), "extbatts": SensorEntityDescription( key="extbatts", + translation_key="external_batteries", name="UPS External Batteries", - icon="mdi:information-outline", ), "firmware": SensorEntityDescription( key="firmware", + translation_key="firmware_version", name="UPS Firmware Version", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "hitrans": SensorEntityDescription( @@ -153,8 +151,8 @@ ), "hostname": SensorEntityDescription( key="hostname", + translation_key="hostname", name="UPS Hostname", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "humidity": SensorEntityDescription( @@ -162,7 +160,6 @@ name="UPS Ambient Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, - icon="mdi:water-percent", state_class=SensorStateClass.MEASUREMENT, ), "itemp": SensorEntityDescription( @@ -174,19 +171,19 @@ ), "laststest": SensorEntityDescription( key="laststest", + translation_key="last_self_test", name="UPS Last Self Test", - icon="mdi:calendar-clock", ), "lastxfer": SensorEntityDescription( key="lastxfer", + translation_key="last_transfer", name="UPS Last Transfer", - icon="mdi:transfer", entity_registry_enabled_default=False, ), "linefail": SensorEntityDescription( key="linefail", + translation_key="line_failure", name="UPS Input Voltage Status", - icon="mdi:information-outline", ), "linefreq": SensorEntityDescription( key="linefreq", @@ -204,16 +201,16 @@ ), "loadpct": SensorEntityDescription( key="loadpct", + translation_key="load_capacity", name="UPS Load", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), "loadapnt": SensorEntityDescription( key="loadapnt", + translation_key="apparent_power", name="UPS Load Apparent Power", native_unit_of_measurement=PERCENTAGE, - icon="mdi:gauge", ), "lotrans": SensorEntityDescription( key="lotrans", @@ -223,14 +220,14 @@ ), "mandate": SensorEntityDescription( key="mandate", + translation_key="manufacture_date", name="UPS Manufacture Date", - icon="mdi:calendar", entity_registry_enabled_default=False, ), "masterupd": SensorEntityDescription( key="masterupd", + translation_key="master_update", name="UPS Master Update", - icon="mdi:information-outline", ), "maxlinev": SensorEntityDescription( key="maxlinev", @@ -240,14 +237,14 @@ ), "maxtime": SensorEntityDescription( key="maxtime", + translation_key="max_time", name="UPS Battery Timeout", - icon="mdi:timer-off-outline", ), "mbattchg": SensorEntityDescription( key="mbattchg", + translation_key="max_battery_charge", name="UPS Battery Shutdown", native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery-alert", ), "minlinev": SensorEntityDescription( key="minlinev", @@ -257,13 +254,13 @@ ), "mintimel": SensorEntityDescription( key="mintimel", + translation_key="min_time", name="UPS Shutdown Time", - icon="mdi:timer-outline", ), "model": SensorEntityDescription( key="model", + translation_key="model", name="UPS Model", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "nombattv": SensorEntityDescription( @@ -298,8 +295,8 @@ ), "numxfers": SensorEntityDescription( key="numxfers", + translation_key="transfer_count", name="UPS Transfer Count", - icon="mdi:counter", state_class=SensorStateClass.TOTAL_INCREASING, ), "outcurnt": SensorEntityDescription( @@ -318,109 +315,109 @@ ), "reg1": SensorEntityDescription( key="reg1", + translation_key="register_1_fault", name="UPS Register 1 Fault", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "reg2": SensorEntityDescription( key="reg2", + translation_key="register_2_fault", name="UPS Register 2 Fault", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "reg3": SensorEntityDescription( key="reg3", + translation_key="register_3_fault", name="UPS Register 3 Fault", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "retpct": SensorEntityDescription( key="retpct", + translation_key="restore_capacity", name="UPS Restore Requirement", native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery-alert", ), "selftest": SensorEntityDescription( key="selftest", + translation_key="self_test_result", name="UPS Self Test result", - icon="mdi:information-outline", ), "sense": SensorEntityDescription( key="sense", + translation_key="sensitivity", name="UPS Sensitivity", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "serialno": SensorEntityDescription( key="serialno", + translation_key="serial_number", name="UPS Serial Number", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "starttime": SensorEntityDescription( key="starttime", + translation_key="startup_time", name="UPS Startup Time", - icon="mdi:calendar-clock", ), "statflag": SensorEntityDescription( key="statflag", + translation_key="online_status", name="UPS Status Flag", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "status": SensorEntityDescription( key="status", + translation_key="status", name="UPS Status", - icon="mdi:information-outline", ), "stesti": SensorEntityDescription( key="stesti", + translation_key="self_test_interval", name="UPS Self Test Interval", - icon="mdi:information-outline", ), "timeleft": SensorEntityDescription( key="timeleft", + translation_key="time_left", name="UPS Time Left", - icon="mdi:clock-alert", state_class=SensorStateClass.MEASUREMENT, ), "tonbatt": SensorEntityDescription( key="tonbatt", + translation_key="time_on_battery", name="UPS Time on Battery", - icon="mdi:timer-outline", state_class=SensorStateClass.TOTAL_INCREASING, ), "upsmode": SensorEntityDescription( key="upsmode", + translation_key="ups_mode", name="UPS Mode", - icon="mdi:information-outline", ), "upsname": SensorEntityDescription( key="upsname", + translation_key="ups_name", name="UPS Name", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "version": SensorEntityDescription( key="version", + translation_key="version", name="UPS Daemon Info", - icon="mdi:information-outline", entity_registry_enabled_default=False, ), "xoffbat": SensorEntityDescription( key="xoffbat", + translation_key="transfer_from_battery", name="UPS Transfer from Battery", - icon="mdi:transfer", ), "xoffbatt": SensorEntityDescription( key="xoffbatt", + translation_key="transfer_from_battery", name="UPS Transfer from Battery", - icon="mdi:transfer", ), "xonbatt": SensorEntityDescription( key="xonbatt", + translation_key="transfer_to_battery", name="UPS Transfer to Battery", - icon="mdi:transfer", ), } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index d012dfc372f81a..01a84cf606ad51 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -12,7 +12,6 @@ from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import ( KEY_HASS, KEY_HASS_USER, @@ -23,6 +22,7 @@ CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + KEY_DATA_LOGGING as DATA_LOGGING, MATCH_ALL, URL_API, URL_API_COMPONENTS, @@ -175,7 +175,7 @@ async def forward_events(event: Event) -> None: msg = f"data: {payload}\n\n" _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) await response.write(msg.encode("UTF-8")) - except asyncio.TimeoutError: + except TimeoutError: await to_write.put(STREAM_PING_PAYLOAD) except asyncio.CancelledError: @@ -222,7 +222,7 @@ def get(self, request: web.Request) -> web.Response: if entity_perm(state.entity_id, "read") ) response = web.Response( - body=b"[" + b",".join(states) + b"]", + body=b"".join((b"[", b",".join(states), b"]")), content_type=CONTENT_TYPE_JSON, zlib_executor_size=32768, ) @@ -472,7 +472,9 @@ class APIErrorLog(HomeAssistantView): async def get(self, request: web.Request) -> web.FileResponse: """Retrieve API error log.""" hass: HomeAssistant = request.app[KEY_HASS] - return web.FileResponse(hass.data[DATA_LOGGING]) + response = web.FileResponse(hass.data[DATA_LOGGING]) + response.enable_compression() + return response async def async_services_json(hass: HomeAssistant) -> list[dict[str, Any]]: diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 8f52db13cfa3f5..c369b07de36de4 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -1,8 +1,10 @@ """The Apple TV integration.""" +from __future__ import annotations + import asyncio import logging from random import randrange -from typing import TYPE_CHECKING, cast +from typing import Any, cast from pyatv import connect, exceptions, scan from pyatv.conf import AppleTV @@ -25,8 +27,8 @@ EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo @@ -40,7 +42,8 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Apple TV" +DEFAULT_NAME_TV = "Apple TV" +DEFAULT_NAME_HP = "HomePod" BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes @@ -56,14 +59,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: manager = AppleTVManager(hass, entry) if manager.is_on: - await manager.connect_once(raise_missing_credentials=True) - if not manager.atv: - address = entry.data[CONF_ADDRESS] - raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery") + address = entry.data[CONF_ADDRESS] + + try: + await manager.async_first_connect() + except ( + exceptions.AuthenticationError, + exceptions.InvalidCredentialsError, + exceptions.NoCredentialsError, + ) as ex: + raise ConfigEntryAuthFailed( + f"{address}: Authentication failed, try reconfiguring device: {ex}" + ) from ex + except ( + asyncio.CancelledError, + exceptions.ConnectionLostError, + exceptions.ConnectionFailedError, + ) as ex: + raise ConfigEntryNotReady(f"{address}: {ex}") from ex + except ( + exceptions.ProtocolError, + exceptions.NoServiceError, + exceptions.PairingError, + exceptions.BackOffError, + exceptions.DeviceIdMissingError, + ) as ex: + _LOGGER.debug( + "Error setting up apple_tv at %s: %s", address, ex, exc_info=ex + ) + raise ConfigEntryNotReady(f"{address}: {ex}") from ex hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager - async def on_hass_stop(event): + async def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" await manager.disconnect() @@ -94,33 +122,29 @@ class AppleTVEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True _attr_name = None + atv: AppleTVInterface | None = None - def __init__( - self, name: str, identifier: str | None, manager: "AppleTVManager" - ) -> None: + def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: """Initialize device.""" - self.atv: AppleTVInterface = None # type: ignore[assignment] self.manager = manager - if TYPE_CHECKING: - assert identifier is not None self._attr_unique_id = identifier self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, name=name, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle when an entity is about to be added to Home Assistant.""" @callback - def _async_connected(atv): + def _async_connected(atv: AppleTVInterface) -> None: """Handle that a connection was made to a device.""" self.atv = atv self.async_device_connected(atv) self.async_write_ha_state() @callback - def _async_disconnected(): + def _async_disconnected() -> None: """Handle that a connection to a device was lost.""" self.async_device_disconnected() self.atv = None @@ -143,10 +167,10 @@ def _async_disconnected(): ) ) - def async_device_connected(self, atv): + def async_device_connected(self, atv: AppleTVInterface) -> None: """Handle when connection is made to device.""" - def async_device_disconnected(self): + def async_device_disconnected(self) -> None: """Handle when connection was lost to device.""" @@ -158,22 +182,23 @@ class AppleTVManager(DeviceListener): in case of problems. """ + atv: AppleTVInterface | None = None + _connection_attempts = 0 + _connection_was_lost = False + _task: asyncio.Task[None] | None = None + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize power manager.""" self.config_entry = config_entry self.hass = hass - self.atv: AppleTVInterface | None = None self.is_on = not config_entry.options.get(CONF_START_OFF, False) - self._connection_attempts = 0 - self._connection_was_lost = False - self._task = None - async def init(self): + async def init(self) -> None: """Initialize power management.""" if self.is_on: await self.connect() - def connection_lost(self, _): + def connection_lost(self, exception: Exception) -> None: """Device was unexpectedly disconnected. This is a callback function from pyatv.interface.DeviceListener. @@ -184,14 +209,14 @@ def connection_lost(self, _): self._connection_was_lost = True self._handle_disconnect() - def connection_closed(self): + def connection_closed(self) -> None: """Device connection was (intentionally) closed. This is a callback function from pyatv.interface.DeviceListener. """ self._handle_disconnect() - def _handle_disconnect(self): + def _handle_disconnect(self) -> None: """Handle that the device disconnected and restart connect loop.""" if self.atv: self.atv.close() @@ -199,12 +224,12 @@ def _handle_disconnect(self): self._dispatch_send(SIGNAL_DISCONNECTED) self._start_connect_loop() - async def connect(self): + async def connect(self) -> None: """Connect to device.""" self.is_on = True self._start_connect_loop() - async def disconnect(self): + async def disconnect(self) -> None: """Disconnect from device.""" _LOGGER.debug("Disconnecting from device") self.is_on = False @@ -218,7 +243,7 @@ async def disconnect(self): except Exception: # pylint: disable=broad-except _LOGGER.exception("An error occurred while disconnecting") - def _start_connect_loop(self): + def _start_connect_loop(self) -> None: """Start background connect loop to device.""" if not self._task and self.atv is None and self.is_on: self._task = asyncio.create_task(self._connect_loop()) @@ -227,11 +252,25 @@ def _start_connect_loop(self): "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) + async def _connect_once(self, raise_missing_credentials: bool) -> None: + """Connect to device once.""" + if conf := await self._scan(): + await self._connect(conf, raise_missing_credentials) + + async def async_first_connect(self) -> None: + """Connect to device for the first time.""" + connect_ok = False + try: + await self._connect_once(raise_missing_credentials=True) + connect_ok = True + finally: + if not connect_ok: + await self.disconnect() + async def connect_once(self, raise_missing_credentials: bool) -> None: """Try to connect once.""" try: - if conf := await self._scan(): - await self._connect(conf, raise_missing_credentials) + await self._connect_once(raise_missing_credentials) except exceptions.AuthenticationError: self.config_entry.async_start_reauth(self.hass) await self.disconnect() @@ -244,9 +283,9 @@ async def connect_once(self, raise_missing_credentials: bool) -> None: pass except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to connect") - self.atv = None + await self.disconnect() - async def _connect_loop(self): + async def _connect_loop(self) -> None: """Connect loop background task function.""" _LOGGER.debug("Starting connect loop") @@ -255,7 +294,8 @@ async def _connect_loop(self): while self.is_on and self.atv is None: await self.connect_once(raise_missing_credentials=False) if self.atv is not None: - break + # Calling self.connect_once may have set self.atv + break # type: ignore[unreachable] self._connection_attempts += 1 backoff = min( max( @@ -352,13 +392,17 @@ async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None self._connection_was_lost = False @callback - def _async_setup_device_registry(self): + def _async_setup_device_registry(self) -> None: attrs = { ATTR_IDENTIFIERS: {(DOMAIN, self.config_entry.unique_id)}, ATTR_MANUFACTURER: "Apple", ATTR_NAME: self.config_entry.data[CONF_NAME], } - attrs[ATTR_SUGGESTED_AREA] = attrs[ATTR_NAME].removesuffix(f" {DEFAULT_NAME}") + attrs[ATTR_SUGGESTED_AREA] = ( + attrs[ATTR_NAME] + .removesuffix(f" {DEFAULT_NAME_TV}") + .removesuffix(f" {DEFAULT_NAME_HP}") + ) if self.atv: dev_info = self.atv.device_info @@ -379,18 +423,18 @@ def _async_setup_device_registry(self): ) @property - def is_connecting(self): + def is_connecting(self) -> bool: """Return true if connection is in progress.""" return self._task is not None - def _address_updated(self, address): + def _address_updated(self, address: str) -> None: """Update cached address in config entry.""" _LOGGER.debug("Changing address to %s", address) self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, CONF_ADDRESS: address} ) - def _dispatch_send(self, signal, *args): + def _dispatch_send(self, signal: str, *args: Any) -> None: """Dispatch a signal to all entities managed by this manager.""" async_dispatcher_send( self.hass, f"{signal}_{self.config_entry.unique_id}", *args diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 11d408ee2ca121..2bb4608dca15f8 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -3,7 +3,7 @@ import asyncio from collections import deque -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Mapping from ipaddress import ip_address import logging from random import randrange @@ -13,12 +13,13 @@ from pyatv.const import DeviceModel, PairingRequirement, Protocol from pyatv.convert import model_str, protocol_str from pyatv.helpers import get_unique_id +from pyatv.interface import BaseConfig, PairingHandler import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,10 +50,12 @@ } -async def device_scan(hass, identifier, loop): +async def device_scan( + hass: HomeAssistant, identifier: str | None, loop: asyncio.AbstractEventLoop +) -> tuple[BaseConfig | None, list[str] | None]: """Scan for a specific device using identifier as filter.""" - def _filter_device(dev): + def _filter_device(dev: BaseConfig) -> bool: if identifier is None: return True if identifier == str(dev.address): @@ -61,9 +64,12 @@ def _filter_device(dev): return True return any(service.identifier == identifier for service in dev.services) - def _host_filter(): + def _host_filter() -> list[str] | None: + if identifier is None: + return None try: - return [ip_address(identifier)] + ip_address(identifier) + return [identifier] except ValueError: return None @@ -84,6 +90,13 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 + scan_filter: str | None = None + atv: BaseConfig | None = None + atv_identifiers: list[str] | None = None + protocol: Protocol | None = None + pairing: PairingHandler | None = None + protocols_to_pair: deque[Protocol] | None = None + @staticmethod @callback def async_get_options_flow( @@ -92,18 +105,12 @@ def async_get_options_flow( """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - def __init__(self): + def __init__(self) -> None: """Initialize a new AppleTVConfigFlow.""" - self.scan_filter = None - self.atv = None - self.atv_identifiers = None - self.protocol = None - self.pairing = None - self.credentials = {} # Protocol -> credentials - self.protocols_to_pair = deque() + self.credentials: dict[int, str | None] = {} # Protocol -> credentials @property - def device_identifier(self): + def device_identifier(self) -> str | None: """Return a identifier for the config entry. A device has multiple unique identifiers, but Home Assistant only supports one @@ -118,6 +125,7 @@ def device_identifier(self): existing config entry. If that's the case, the unique_id from that entry is re-used, otherwise the newly discovered identifier is used instead. """ + assert self.atv all_identifiers = set(self.atv.all_identifiers) if unique_id := self._entry_unique_id_from_identifers(all_identifiers): return unique_id @@ -143,7 +151,9 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: self.context["identifier"] = self.unique_id return await self.async_step_reconfigure() - async def async_step_reconfigure(self, user_input=None): + async def async_step_reconfigure( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that reconfiguration is about to start.""" if user_input is not None: return await self.async_find_device_wrapper( @@ -152,7 +162,9 @@ async def async_step_reconfigure(self, user_input=None): return self.async_show_form(step_id="reconfigure") - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -170,6 +182,7 @@ async def async_step_user(self, user_input=None): await self.async_set_unique_id( self.device_identifier, raise_on_progress=False ) + assert self.atv self.context["all_identifiers"] = self.atv.all_identifiers return await self.async_step_confirm() @@ -275,8 +288,11 @@ def _async_check_and_update_in_progress(self, host: str, unique_id: str) -> None context["all_identifiers"].append(unique_id) raise AbortFlow("already_in_progress") - async def async_found_zeroconf_device(self, user_input=None): + async def async_found_zeroconf_device( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle device found after Zeroconf discovery.""" + assert self.atv self.context["all_identifiers"] = self.atv.all_identifiers # Also abort if an integration with this identifier already exists await self.async_set_unique_id(self.device_identifier) @@ -288,7 +304,11 @@ async def async_found_zeroconf_device(self, user_input=None): self.context["identifier"] = self.unique_id return await self.async_step_confirm() - async def async_find_device_wrapper(self, next_func, allow_exist=False): + async def async_find_device_wrapper( + self, + next_func: Callable[[], Awaitable[FlowResult]], + allow_exist: bool = False, + ) -> FlowResult: """Find a specific device and call another function when done. This function will do error handling and bail out when an error @@ -306,7 +326,7 @@ async def async_find_device_wrapper(self, next_func, allow_exist=False): return await next_func() - async def async_find_device(self, allow_exist=False): + async def async_find_device(self, allow_exist: bool = False) -> None: """Scan for the selected device to discover services.""" self.atv, self.atv_identifiers = await device_scan( self.hass, self.scan_filter, self.hass.loop @@ -357,8 +377,11 @@ async def async_find_device(self, allow_exist=False): if not allow_exist: raise DeviceAlreadyConfigured() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" + assert self.atv if user_input is not None: expected_identifier_count = len(self.context["all_identifiers"]) # If number of services found during device scan mismatch number of @@ -384,7 +407,7 @@ async def async_step_confirm(self, user_input=None): }, ) - async def async_pair_next_protocol(self): + async def async_pair_next_protocol(self) -> FlowResult: """Start pairing process for the next available protocol.""" await self._async_cleanup() @@ -393,8 +416,16 @@ async def async_pair_next_protocol(self): return await self._async_get_entry() self.protocol = self.protocols_to_pair.popleft() + assert self.atv service = self.atv.get_service(self.protocol) + if service is None: + _LOGGER.debug( + "%s does not support pairing (cannot find a corresponding service)", + self.protocol, + ) + return await self.async_pair_next_protocol() + # Service requires a password if service.requires_password: return await self.async_step_password() @@ -413,7 +444,7 @@ async def async_pair_next_protocol(self): _LOGGER.debug("%s requires pairing", self.protocol) # Protocol specific arguments - pair_args = {} + pair_args: dict[str, Any] = {} if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: pair_args["name"] = "Home Assistant" if self.protocol == Protocol.DMAP: @@ -448,8 +479,11 @@ async def async_pair_next_protocol(self): return await self.async_step_pair_no_pin() - async def async_step_protocol_disabled(self, user_input=None): + async def async_step_protocol_disabled( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that a protocol is disabled and cannot be paired.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() return self.async_show_form( @@ -457,9 +491,13 @@ async def async_step_protocol_disabled(self, user_input=None): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_pair_with_pin(self, user_input=None): + async def async_step_pair_with_pin( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle pairing step where a PIN is required from the user.""" errors = {} + assert self.pairing + assert self.protocol if user_input is not None: try: self.pairing.pin(user_input[CONF_PIN]) @@ -480,8 +518,12 @@ async def async_step_pair_with_pin(self, user_input=None): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_pair_no_pin(self, user_input=None): + async def async_step_pair_no_pin( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle step where user has to enter a PIN on the device.""" + assert self.pairing + assert self.protocol if user_input is not None: await self.pairing.finish() if self.pairing.has_paired: @@ -497,12 +539,15 @@ async def async_step_pair_no_pin(self, user_input=None): step_id="pair_no_pin", description_placeholders={ "protocol": protocol_str(self.protocol), - "pin": pin, + "pin": str(pin), }, ) - async def async_step_service_problem(self, user_input=None): + async def async_step_service_problem( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that a service will not be added.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() @@ -511,8 +556,11 @@ async def async_step_service_problem(self, user_input=None): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def async_step_password(self, user_input=None): + async def async_step_password( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Inform user that password is not supported.""" + assert self.protocol if user_input is not None: return await self.async_pair_next_protocol() @@ -521,18 +569,20 @@ async def async_step_password(self, user_input=None): description_placeholders={"protocol": protocol_str(self.protocol)}, ) - async def _async_cleanup(self): + async def _async_cleanup(self) -> None: """Clean up allocated resources.""" if self.pairing is not None: await self.pairing.close() self.pairing = None - async def _async_get_entry(self): + async def _async_get_entry(self) -> FlowResult: """Return config entry or update existing config entry.""" # Abort if no protocols were paired if not self.credentials: return self.async_abort(reason="setup_failed") + assert self.atv + data = { CONF_NAME: self.atv.name, CONF_CREDENTIALS: self.credentials, diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 1f7ac45372edd8..0a14e11ecb78ee 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["zeroconf"], "documentation": "https://www.home-assistant.io/integrations/apple_tv", + "import_executor": true, "iot_class": "local_push", "loggers": ["pyatv", "srptools"], "requirements": ["pyatv==0.14.3"], diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 789415a1717557..a7b5957ecff70d 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -16,7 +16,15 @@ ShuffleState, ) from pyatv.helpers import is_streamable -from pyatv.interface import AppleTV, Playing +from pyatv.interface import ( + AppleTV, + AudioListener, + OutputDevice, + Playing, + PowerListener, + PushListener, + PushUpdater, +) from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -101,7 +109,9 @@ async def async_setup_entry( async_add_entities([AppleTvMediaPlayer(name, config_entry.unique_id, manager)]) -class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): +class AppleTvMediaPlayer( + AppleTVEntity, MediaPlayerEntity, PowerListener, AudioListener, PushListener +): """Representation of an Apple TV media player.""" _attr_supported_features = SUPPORT_APPLE_TV @@ -116,9 +126,9 @@ def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: def async_device_connected(self, atv: AppleTV) -> None: """Handle when connection is made to device.""" # NB: Do not use _is_feature_available here as it only works when playing - if self.atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): - self.atv.push_updater.listener = self - self.atv.push_updater.start() + if atv.features.in_state(FeatureState.Available, FeatureName.PushUpdates): + atv.push_updater.listener = self + atv.push_updater.start() self._attr_supported_features = SUPPORT_BASE @@ -126,7 +136,7 @@ def async_device_connected(self, atv: AppleTV) -> None: # "Unsupported" are considered here as the state of such a feature can never # change after a connection has been established, i.e. an unsupported feature # can never change to be supported. - all_features = self.atv.features.all_features() + all_features = atv.features.all_features() for feature_name, support_flag in SUPPORT_FEATURE_MAPPING.items(): feature_info = all_features.get(feature_name) if feature_info and feature_info.state != FeatureState.Unsupported: @@ -136,16 +146,18 @@ def async_device_connected(self, atv: AppleTV) -> None: # metadata update arrives (sometime very soon after this callback returns) # Listen to power updates - self.atv.power.listener = self + atv.power.listener = self # Listen to volume updates - self.atv.audio.listener = self + atv.audio.listener = self - if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): + if atv.features.in_state(FeatureState.Available, FeatureName.AppList): self.hass.create_task(self._update_app_list()) async def _update_app_list(self) -> None: _LOGGER.debug("Updating app list") + if not self.atv: + return try: apps = await self.atv.apps.app_list() except exceptions.NotSupportedError: @@ -189,33 +201,56 @@ def state(self) -> MediaPlayerState | None: return None @callback - def playstatus_update(self, _, playing: Playing) -> None: - """Print what is currently playing when it changes.""" - self._playing = playing + def playstatus_update(self, updater: PushUpdater, playstatus: Playing) -> None: + """Print what is currently playing when it changes. + + This is a callback function from pyatv.interface.PushListener. + """ + self._playing = playstatus self.async_write_ha_state() @callback - def playstatus_error(self, _, exception: Exception) -> None: - """Inform about an error and restart push updates.""" + def playstatus_error(self, updater: PushUpdater, exception: Exception) -> None: + """Inform about an error and restart push updates. + + This is a callback function from pyatv.interface.PushListener. + """ _LOGGER.warning("A %s error occurred: %s", exception.__class__, exception) self._playing = None self.async_write_ha_state() @callback def powerstate_update(self, old_state: PowerState, new_state: PowerState) -> None: - """Update power state when it changes.""" + """Update power state when it changes. + + This is a callback function from pyatv.interface.PowerListener. + """ self.async_write_ha_state() @callback def volume_update(self, old_level: float, new_level: float) -> None: - """Update volume when it changes.""" + """Update volume when it changes. + + This is a callback function from pyatv.interface.AudioListener. + """ self.async_write_ha_state() + @callback + def outputdevices_update( + self, old_devices: list[OutputDevice], new_devices: list[OutputDevice] + ) -> None: + """Output devices were updated. + + This is a callback function from pyatv.interface.AudioListener. + """ + @property def app_id(self) -> str | None: """ID of the current running app.""" - if self._is_feature_available(FeatureName.App) and ( - app := self.atv.metadata.app + if ( + self.atv + and self._is_feature_available(FeatureName.App) + and (app := self.atv.metadata.app) is not None ): return app.identifier return None @@ -223,8 +258,10 @@ def app_id(self) -> str | None: @property def app_name(self) -> str | None: """Name of the current running app.""" - if self._is_feature_available(FeatureName.App) and ( - app := self.atv.metadata.app + if ( + self.atv + and self._is_feature_available(FeatureName.App) + and (app := self.atv.metadata.app) is not None ): return app.name return None @@ -255,7 +292,7 @@ def media_content_id(self) -> str | None: @property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" - if self._is_feature_available(FeatureName.Volume): + if self.atv and self._is_feature_available(FeatureName.Volume): return self.atv.audio.volume / 100.0 # from percent return None @@ -286,6 +323,8 @@ async def async_play_media( """Send the play_media command to the media player.""" # If input (file) has a file format supported by pyatv, then stream it with # RAOP. Otherwise try to play it with regular AirPlay. + if not self.atv: + return if media_type in {MediaType.APP, MediaType.URL}: await self.atv.apps.launch_app(media_id) return @@ -313,7 +352,8 @@ def media_image_hash(self) -> str | None: """Hash value for media image.""" state = self.state if ( - self._playing + self.atv + and self._playing and self._is_feature_available(FeatureName.Artwork) and state not in {None, MediaPlayerState.OFF, MediaPlayerState.IDLE} ): @@ -323,7 +363,11 @@ def media_image_hash(self) -> str | None: async def async_get_media_image(self) -> tuple[bytes | None, str | None]: """Fetch media image of current playing image.""" state = self.state - if self._playing and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE}: + if ( + self.atv + and self._playing + and state not in {MediaPlayerState.OFF, MediaPlayerState.IDLE} + ): artwork = await self.atv.metadata.artwork() if artwork: return artwork.bytes, artwork.mimetype @@ -439,20 +483,24 @@ async def async_browse_media( async def async_turn_on(self) -> None: """Turn the media player on.""" - if self._is_feature_available(FeatureName.TurnOn): + if self.atv and self._is_feature_available(FeatureName.TurnOn): await self.atv.power.turn_on() async def async_turn_off(self) -> None: """Turn the media player off.""" - if (self._is_feature_available(FeatureName.TurnOff)) and ( - not self._is_feature_available(FeatureName.PowerState) - or self.atv.power.power_state == PowerState.On + if ( + self.atv + and (self._is_feature_available(FeatureName.TurnOff)) + and ( + not self._is_feature_available(FeatureName.PowerState) + or self.atv.power.power_state == PowerState.On + ) ): await self.atv.power.turn_off() async def async_media_play_pause(self) -> None: """Pause media on media player.""" - if self._playing: + if self.atv and self._playing: await self.atv.remote_control.play_pause() async def async_media_play(self) -> None: @@ -519,5 +567,6 @@ async def async_set_shuffle(self, shuffle: bool) -> None: async def async_select_source(self, source: str) -> None: """Select input source.""" - if app_id := self._app_list.get(source): - await self.atv.apps.launch_app(app_id) + if self.atv: + if app_id := self._app_list.get(source): + await self.atv.apps.launch_app(app_id) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 24d2ef68ed4dbe..7baa6321f2153c 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AppleTVEntity +from . import AppleTVEntity, AppleTVManager from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -38,8 +38,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Load Apple TV remote based on a config entry.""" - name = config_entry.data[CONF_NAME] - manager = hass.data[DOMAIN][config_entry.unique_id] + name: str = config_entry.data[CONF_NAME] + # apple_tv config entries always have a unique id + assert config_entry.unique_id is not None + manager: AppleTVManager = hass.data[DOMAIN][config_entry.unique_id] async_add_entities([AppleTVRemote(name, config_entry.unique_id, manager)]) @@ -47,7 +49,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): """Device that sends commands to an Apple TV.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self.atv is not None @@ -64,13 +66,13 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non num_repeats = kwargs[ATTR_NUM_REPEATS] delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) - if not self.is_on: + if not self.atv: _LOGGER.error("Unable to send commands, not connected to %s", self.name) return for _ in range(num_repeats): for single_command in command: - attr_value = None + attr_value: Any = None if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): attr_value = self.atv for attr_name in attributes: @@ -81,5 +83,5 @@ async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> Non raise ValueError("Command not found. Exiting sequence") _LOGGER.info("Sending command %s", single_command) - await attr_value() # type: ignore[operator] + await attr_value() await asyncio.sleep(delay) diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py new file mode 100644 index 00000000000000..b5aeea2a55ca7a --- /dev/null +++ b/homeassistant/components/aprilaire/__init__.py @@ -0,0 +1,69 @@ +"""The Aprilaire integration.""" + +from __future__ import annotations + +import logging + +from pyaprilaire.const import Attribute + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry for Aprilaire.""" + + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + coordinator = AprilaireCoordinator(hass, entry.unique_id, host, port) + await coordinator.start_listen() + + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = coordinator + + async def ready_callback(ready: bool): + if ready: + mac_address = format_mac(coordinator.data[Attribute.MAC_ADDRESS]) + + if mac_address != entry.unique_id: + raise ConfigEntryAuthFailed("Invalid MAC address") + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_close(_: Event) -> None: + coordinator.stop_listen() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) + ) + else: + _LOGGER.error("Failed to wait for ready") + + coordinator.stop_listen() + + raise ConfigEntryNotReady() + + await coordinator.wait_for_ready(ready_callback) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + coordinator: AprilaireCoordinator = hass.data[DOMAIN].pop(entry.unique_id) + coordinator.stop_listen() + + return unload_ok diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py new file mode 100644 index 00000000000000..96c1e1ac9818a2 --- /dev/null +++ b/homeassistant/components/aprilaire/climate.py @@ -0,0 +1,302 @@ +"""The Aprilaire climate component.""" + +from __future__ import annotations + +from typing import Any + +from pyaprilaire.const import Attribute + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_ON, + PRESET_AWAY, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + FAN_CIRCULATE, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION, +) +from .coordinator import AprilaireCoordinator +from .entity import BaseAprilaireEntity + +HVAC_MODE_MAP = { + 1: HVACMode.OFF, + 2: HVACMode.HEAT, + 3: HVACMode.COOL, + 4: HVACMode.HEAT, + 5: HVACMode.AUTO, +} + +HVAC_MODES_MAP = { + 1: [HVACMode.OFF, HVACMode.HEAT], + 2: [HVACMode.OFF, HVACMode.COOL], + 3: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 4: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL], + 5: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], + 6: [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO], +} + +PRESET_MODE_MAP = { + 1: PRESET_TEMPORARY_HOLD, + 2: PRESET_PERMANENT_HOLD, + 3: PRESET_AWAY, + 4: PRESET_VACATION, +} + +FAN_MODE_MAP = { + 1: FAN_ON, + 2: FAN_AUTO, + 3: FAN_CIRCULATE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add climates for passed config_entry in HA.""" + + coordinator: AprilaireCoordinator = hass.data[DOMAIN][config_entry.unique_id] + + async_add_entities([AprilaireClimate(coordinator, config_entry.unique_id)]) + + +class AprilaireClimate(BaseAprilaireEntity, ClimateEntity): + """Climate entity for Aprilaire.""" + + _attr_fan_modes = [FAN_AUTO, FAN_ON, FAN_CIRCULATE] + _attr_min_humidity = 10 + _attr_max_humidity = 50 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "thermostat" + + @property + def precision(self) -> float: + """Get the precision based on the unit.""" + return ( + PRECISION_HALVES + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else PRECISION_WHOLE + ) + + @property + def supported_features(self) -> ClimateEntityFeature: + """Get supported features.""" + features = 0 + + if self.coordinator.data.get(Attribute.MODE) == 5: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + else: + features = features | ClimateEntityFeature.TARGET_TEMPERATURE + + if self.coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) == 2: + features = features | ClimateEntityFeature.TARGET_HUMIDITY + + features = features | ClimateEntityFeature.PRESET_MODE + + features = features | ClimateEntityFeature.FAN_MODE + + return features + + @property + def current_humidity(self) -> int | None: + """Get current humidity.""" + return self.coordinator.data.get( + Attribute.INDOOR_HUMIDITY_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_humidity(self) -> int | None: + """Get current target humidity.""" + return self.coordinator.data.get(Attribute.HUMIDIFICATION_SETPOINT) + + @property + def hvac_mode(self) -> HVACMode | None: + """Get HVAC mode.""" + + if mode := self.coordinator.data.get(Attribute.MODE): + if hvac_mode := HVAC_MODE_MAP.get(mode): + return hvac_mode + + return None + + @property + def hvac_modes(self) -> list[HVACMode]: + """Get supported HVAC modes.""" + + if modes := self.coordinator.data.get(Attribute.THERMOSTAT_MODES): + if thermostat_modes := HVAC_MODES_MAP.get(modes): + return thermostat_modes + + return [] + + @property + def hvac_action(self) -> HVACAction | None: + """Get the current HVAC action.""" + + if self.coordinator.data.get(Attribute.HEATING_EQUIPMENT_STATUS, 0): + return HVACAction.HEATING + + if self.coordinator.data.get(Attribute.COOLING_EQUIPMENT_STATUS, 0): + return HVACAction.COOLING + + return HVACAction.IDLE + + @property + def current_temperature(self) -> float | None: + """Get current temperature.""" + return self.coordinator.data.get( + Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_VALUE + ) + + @property + def target_temperature(self) -> float | None: + """Get the target temperature.""" + + hvac_mode = self.hvac_mode + + if hvac_mode == HVACMode.COOL: + return self.target_temperature_high + if hvac_mode == HVACMode.HEAT: + return self.target_temperature_low + + return None + + @property + def target_temperature_step(self) -> float | None: + """Get the step for the target temperature based on the unit.""" + return ( + 0.5 + if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS + else 1 + ) + + @property + def target_temperature_high(self) -> float | None: + """Get cool setpoint.""" + return self.coordinator.data.get(Attribute.COOL_SETPOINT) + + @property + def target_temperature_low(self) -> float | None: + """Get heat setpoint.""" + return self.coordinator.data.get(Attribute.HEAT_SETPOINT) + + @property + def preset_mode(self) -> str | None: + """Get the current preset mode.""" + if hold := self.coordinator.data.get(Attribute.HOLD): + if preset_mode := PRESET_MODE_MAP.get(hold): + return preset_mode + + return PRESET_NONE + + @property + def preset_modes(self) -> list[str] | None: + """Get the supported preset modes.""" + presets = [PRESET_NONE, PRESET_VACATION] + + if self.coordinator.data.get(Attribute.AWAY_AVAILABLE) == 1: + presets.append(PRESET_AWAY) + + hold = self.coordinator.data.get(Attribute.HOLD, 0) + + if hold == 1: + presets.append(PRESET_TEMPORARY_HOLD) + elif hold == 2: + presets.append(PRESET_PERMANENT_HOLD) + + return presets + + @property + def fan_mode(self) -> str | None: + """Get fan mode.""" + + if mode := self.coordinator.data.get(Attribute.FAN_MODE): + if fan_mode := FAN_MODE_MAP.get(mode): + return fan_mode + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + cool_setpoint = 0 + heat_setpoint = 0 + + if temperature := kwargs.get("temperature"): + if self.coordinator.data.get(Attribute.MODE) == 3: + cool_setpoint = temperature + else: + heat_setpoint = temperature + else: + if target_temp_low := kwargs.get("target_temp_low"): + heat_setpoint = target_temp_low + if target_temp_high := kwargs.get("target_temp_high"): + cool_setpoint = target_temp_high + + if cool_setpoint == 0 and heat_setpoint == 0: + return + + await self.coordinator.client.update_setpoint(cool_setpoint, heat_setpoint) + + await self.coordinator.client.read_control() + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidification setpoint.""" + + await self.coordinator.client.set_humidification_setpoint(humidity) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + + try: + fan_mode_value_index = list(FAN_MODE_MAP.values()).index(fan_mode) + except ValueError as exc: + raise ValueError(f"Unsupported fan mode {fan_mode}") from exc + + fan_mode_value = list(FAN_MODE_MAP.keys())[fan_mode_value_index] + + await self.coordinator.client.update_fan_mode(fan_mode_value) + + await self.coordinator.client.read_control() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + + try: + mode_value_index = list(HVAC_MODE_MAP.values()).index(hvac_mode) + except ValueError as exc: + raise ValueError(f"Unsupported HVAC mode {hvac_mode}") from exc + + mode_value = list(HVAC_MODE_MAP.keys())[mode_value_index] + + await self.coordinator.client.update_mode(mode_value) + + await self.coordinator.client.read_control() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + + if preset_mode == PRESET_AWAY: + await self.coordinator.client.set_hold(3) + elif preset_mode == PRESET_VACATION: + await self.coordinator.client.set_hold(4) + elif preset_mode == PRESET_NONE: + await self.coordinator.client.set_hold(0) + else: + raise ValueError(f"Unsupported preset mode {preset_mode}") + + await self.coordinator.client.read_scheduling() diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py new file mode 100644 index 00000000000000..0e38b38545037d --- /dev/null +++ b/homeassistant/components/aprilaire/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow for the Aprilaire integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyaprilaire.const import Attribute +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN +from .coordinator import AprilaireCoordinator + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=7000): cv.port, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Aprilaire.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + coordinator = AprilaireCoordinator( + self.hass, None, user_input[CONF_HOST], user_input[CONF_PORT] + ) + await coordinator.start_listen() + + async def ready_callback(ready: bool): + if not ready: + _LOGGER.error("Failed to wait for ready") + + try: + ready = await coordinator.wait_for_ready(ready_callback) + finally: + coordinator.stop_listen() + + mac_address = coordinator.data.get(Attribute.MAC_ADDRESS) + + if ready and mac_address is not None: + await self.async_set_unique_id(format_mac(mac_address)) + + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Aprilaire", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "connection_failed"}, + ) diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py new file mode 100644 index 00000000000000..baf92294266111 --- /dev/null +++ b/homeassistant/components/aprilaire/const.py @@ -0,0 +1,11 @@ +"""Constants for the Aprilaire integration.""" + +from __future__ import annotations + +DOMAIN = "aprilaire" + +FAN_CIRCULATE = "Circulate" + +PRESET_TEMPORARY_HOLD = "Temporary" +PRESET_PERMANENT_HOLD = "Permanent" +PRESET_VACATION = "Vacation" diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py new file mode 100644 index 00000000000000..7a67dee46a890d --- /dev/null +++ b/homeassistant/components/aprilaire/coordinator.py @@ -0,0 +1,209 @@ +"""The Aprilaire coordinator.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +import logging +from typing import Any, Optional + +import pyaprilaire.client +from pyaprilaire.const import MODELS, Attribute, FunctionalDomain + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol + +from .const import DOMAIN + +RECONNECT_INTERVAL = 60 * 60 +RETRY_CONNECTION_INTERVAL = 10 +WAIT_TIMEOUT = 30 + +_LOGGER = logging.getLogger(__name__) + + +class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol): + """Coordinator for interacting with the thermostat.""" + + def __init__( + self, + hass: HomeAssistant, + unique_id: str | None, + host: str, + port: int, + ) -> None: + """Initialize the coordinator.""" + + self.hass = hass + self.unique_id = unique_id + self.data: dict[str, Any] = {} + + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + + self.client = pyaprilaire.client.AprilaireClient( + host, + port, + self.async_set_updated_data, + _LOGGER, + RECONNECT_INTERVAL, + RETRY_CONNECTION_INTERVAL, + ) + + if hasattr(self.client, "data") and self.client.data: + self.data = self.client.data + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + + self._listeners[remove_listener] = (update_callback, context) + + return remove_listener + + @callback + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() + + def async_set_updated_data(self, data: Any) -> None: + """Manually update data, notify listeners and reset refresh interval.""" + + old_device_info = self.create_device_info(self.data) + + self.data = self.data | data + + self.async_update_listeners() + + new_device_info = self.create_device_info(data) + + if ( + old_device_info is not None + and new_device_info is not None + and old_device_info != new_device_info + ): + device_registry = dr.async_get(self.hass) + + device = device_registry.async_get_device(old_device_info["identifiers"]) + + if device is not None: + new_device_info.pop("identifiers", None) + new_device_info.pop("connections", None) + + device_registry.async_update_device( + device_id=device.id, + **new_device_info, # type: ignore[misc] + ) + + async def start_listen(self): + """Start listening for data.""" + await self.client.start_listen() + + def stop_listen(self): + """Stop listening for data.""" + self.client.stop_listen() + + async def wait_for_ready( + self, ready_callback: Callable[[bool], Awaitable[bool]] + ) -> bool: + """Wait for the client to be ready.""" + + if not self.data or Attribute.MAC_ADDRESS not in self.data: + data = await self.client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 2, WAIT_TIMEOUT + ) + + if not data or Attribute.MAC_ADDRESS not in data: + _LOGGER.error("Missing MAC address") + await ready_callback(False) + + return False + + if not self.data or Attribute.NAME not in self.data: + await self.client.wait_for_response( + FunctionalDomain.IDENTIFICATION, 4, WAIT_TIMEOUT + ) + + if not self.data or Attribute.THERMOSTAT_MODES not in self.data: + await self.client.wait_for_response( + FunctionalDomain.CONTROL, 7, WAIT_TIMEOUT + ) + + if ( + not self.data + or Attribute.INDOOR_TEMPERATURE_CONTROLLING_SENSOR_STATUS not in self.data + ): + await self.client.wait_for_response( + FunctionalDomain.SENSORS, 2, WAIT_TIMEOUT + ) + + await ready_callback(True) + + return True + + @property + def device_name(self) -> str: + """Get the name of the thermostat.""" + + return self.create_device_name(self.data) + + def create_device_name(self, data: Optional[dict[str, Any]]) -> str: + """Create the name of the thermostat.""" + + name = data.get(Attribute.NAME) if data else None + + return name if name else "Aprilaire" + + def get_hw_version(self, data: dict[str, Any]) -> str: + """Get the hardware version.""" + + if hardware_revision := data.get(Attribute.HARDWARE_REVISION): + return ( + f"Rev. {chr(hardware_revision)}" + if hardware_revision > ord("A") + else str(hardware_revision) + ) + + return "Unknown" + + @property + def device_info(self) -> DeviceInfo | None: + """Get the device info for the thermostat.""" + return self.create_device_info(self.data) + + def create_device_info(self, data: dict[str, Any]) -> DeviceInfo | None: + """Create the device info for the thermostat.""" + + if data is None or Attribute.MAC_ADDRESS not in data or self.unique_id is None: + return None + + device_info = DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.create_device_name(data), + manufacturer="Aprilaire", + ) + + model_number = data.get(Attribute.MODEL_NUMBER) + if model_number is not None: + device_info["model"] = MODELS.get(model_number, f"Unknown ({model_number})") + + device_info["hw_version"] = self.get_hw_version(data) + + firmware_major_revision = data.get(Attribute.FIRMWARE_MAJOR_REVISION) + firmware_minor_revision = data.get(Attribute.FIRMWARE_MINOR_REVISION) + if firmware_major_revision is not None: + device_info["sw_version"] = ( + str(firmware_major_revision) + if firmware_minor_revision is None + else f"{firmware_major_revision}.{firmware_minor_revision:02}" + ) + + return device_info diff --git a/homeassistant/components/aprilaire/entity.py b/homeassistant/components/aprilaire/entity.py new file mode 100644 index 00000000000000..e2f2bf109ef2bc --- /dev/null +++ b/homeassistant/components/aprilaire/entity.py @@ -0,0 +1,46 @@ +"""Base functionality for Aprilaire entities.""" + +from __future__ import annotations + +import logging + +from pyaprilaire.const import Attribute + +from homeassistant.helpers.update_coordinator import BaseCoordinatorEntity + +from .coordinator import AprilaireCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class BaseAprilaireEntity(BaseCoordinatorEntity[AprilaireCoordinator]): + """Base for Aprilaire entities.""" + + _attr_available = False + _attr_has_entity_name = True + + def __init__( + self, coordinator: AprilaireCoordinator, unique_id: str | None + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{unique_id}_{self.translation_key}" + + self._update_available() + + def _update_available(self): + """Update the entity availability.""" + + connected: bool = self.coordinator.data.get( + Attribute.CONNECTED, None + ) or self.coordinator.data.get(Attribute.RECONNECTING, None) + + stopped: bool = self.coordinator.data.get(Attribute.STOPPED, None) + + self._attr_available = connected and not stopped + + async def async_update(self) -> None: + """Implement abstract base method.""" diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json new file mode 100644 index 00000000000000..43ba4417638514 --- /dev/null +++ b/homeassistant/components/aprilaire/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "aprilaire", + "name": "Aprilaire", + "codeowners": ["@chamberlain2007"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aprilaire", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["pyaprilaire"], + "requirements": ["pyaprilaire==0.7.0"] +} diff --git a/homeassistant/components/aprilaire/strings.json b/homeassistant/components/aprilaire/strings.json new file mode 100644 index 00000000000000..e996691f21fb5e --- /dev/null +++ b/homeassistant/components/aprilaire/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "Usually 7000 or 8000" + } + } + }, + "error": { + "connection_failed": "Connection failed. Please check that the host and port is correct." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "climate": { + "thermostat": { + "name": "Thermostat" + } + } + } +} diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d9ab17dba86009..a45dd89e1807dd 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -83,7 +83,7 @@ def _listen(_: Any) -> None: except ConnectionFailed: await asyncio.sleep(interval) - except asyncio.TimeoutError: + except TimeoutError: continue except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception, aborting arcam client") diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 7c4ec280101b71..7ec5bcdfa6409b 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -325,9 +325,7 @@ def volume_level(self) -> float | None: def media_content_type(self) -> MediaType | None: """Content type of current playing media.""" source = self._state.get_source() - if source == SourceCodes.DAB: - value = MediaType.MUSIC - elif source == SourceCodes.FM: + if source in (SourceCodes.DAB, SourceCodes.FM): value = MediaType.MUSIC else: value = None diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7f6bef6e3c0c95..a009cfb1095af4 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -83,6 +83,7 @@ async def async_pipeline_from_audio_stream( event_callback: PipelineEventCallback, stt_metadata: stt.SpeechMetadata, stt_stream: AsyncIterable[bytes], + wake_word_phrase: str | None = None, pipeline_id: str | None = None, conversation_id: str | None = None, tts_audio_output: str | None = None, @@ -101,6 +102,7 @@ async def async_pipeline_from_audio_stream( device_id=device_id, stt_metadata=stt_metadata, stt_stream=stt_stream, + wake_word_phrase=wake_word_phrase, run=PipelineRun( hass, context=context, diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 091b19db69e097..ef1ed1177a6981 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -10,6 +10,6 @@ CONF_DEBUG_RECORDING_DIR = "debug_recording_dir" DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up" -DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds +WAKE_WORD_COOLDOWN = 2 # seconds EVENT_RECORDING = f"{DOMAIN}_recording" diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index 209e2611ec0f42..8b72331817c3ba 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -38,6 +38,17 @@ class SpeechToTextError(PipelineError): """Error in speech-to-text portion of pipeline.""" +class DuplicateWakeUpDetectedError(WakeWordDetectionError): + """Error when multiple voice assistants wake up at the same time (same wake word).""" + + def __init__(self, wake_up_phrase: str) -> None: + """Set error message.""" + super().__init__( + "duplicate_wake_up_detected", + f"Duplicate wake-up detected for {wake_up_phrase}", + ) + + class IntentRecognitionError(PipelineError): """Error in intent recognition portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a98f184094fe30..bf511f6cff5887 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -55,10 +55,11 @@ CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, - DEFAULT_WAKE_WORD_COOLDOWN, DOMAIN, + WAKE_WORD_COOLDOWN, ) from .error import ( + DuplicateWakeUpDetectedError, IntentRecognitionError, PipelineError, PipelineNotFound, @@ -453,9 +454,6 @@ class WakeWordSettings: audio_seconds_to_buffer: float = 0 """Seconds of audio to buffer before detection and forward to STT.""" - cooldown_seconds: float = DEFAULT_WAKE_WORD_COOLDOWN - """Seconds after a wake word detection where other detections are ignored.""" - @dataclass(frozen=True) class AudioSettings: @@ -742,16 +740,22 @@ async def wake_word_detection( wake_word_output: dict[str, Any] = {} else: # Avoid duplicate detections by checking cooldown - wake_up_key = f"{self.wake_word_entity_id}.{result.wake_word_id}" - last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(wake_up_key) + last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get( + result.wake_word_phrase + ) if last_wake_up is not None: sec_since_last_wake_up = time.monotonic() - last_wake_up - if sec_since_last_wake_up < wake_word_settings.cooldown_seconds: - _LOGGER.debug("Duplicate wake word detection occurred") - raise WakeWordDetectionAborted + if sec_since_last_wake_up < WAKE_WORD_COOLDOWN: + _LOGGER.debug( + "Duplicate wake word detection occurred for %s", + result.wake_word_phrase, + ) + raise DuplicateWakeUpDetectedError(result.wake_word_phrase) # Record last wake up time to block duplicate detections - self.hass.data[DATA_LAST_WAKE_UP][wake_up_key] = time.monotonic() + self.hass.data[DATA_LAST_WAKE_UP][ + result.wake_word_phrase + ] = time.monotonic() if result.queued_audio: # Add audio that was pending at detection. @@ -1308,6 +1312,9 @@ class PipelineInput: stt_stream: AsyncIterable[bytes] | None = None """Input audio for stt. Required when start_stage = stt.""" + wake_word_phrase: str | None = None + """Optional key used to de-duplicate wake-ups for local wake word detection.""" + intent_input: str | None = None """Input for conversation agent. Required when start_stage = intent.""" @@ -1352,6 +1359,25 @@ async def execute(self) -> None: assert self.stt_metadata is not None assert stt_processed_stream is not None + if self.wake_word_phrase is not None: + # Avoid duplicate wake-ups by checking cooldown + last_wake_up = self.run.hass.data[DATA_LAST_WAKE_UP].get( + self.wake_word_phrase + ) + if last_wake_up is not None: + sec_since_last_wake_up = time.monotonic() - last_wake_up + if sec_since_last_wake_up < WAKE_WORD_COOLDOWN: + _LOGGER.debug( + "Speech-to-text cancelled to avoid duplicate wake-up for %s", + self.wake_word_phrase, + ) + raise DuplicateWakeUpDetectedError(self.wake_word_phrase) + + # Record last wake up time to block duplicate detections + self.run.hass.data[DATA_LAST_WAKE_UP][ + self.wake_word_phrase + ] = time.monotonic() + stt_input_stream = stt_processed_stream if stt_audio_buffer: diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index bfba856387535c..f7a6d3c43fa594 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -97,7 +97,12 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: extra=vol.ALLOW_EXTRA, ), PipelineStage.STT: vol.Schema( - {vol.Required("input"): {vol.Required("sample_rate"): int}}, + { + vol.Required("input"): { + vol.Required("sample_rate"): int, + vol.Optional("wake_word_phrase"): str, + } + }, extra=vol.ALLOW_EXTRA, ), PipelineStage.INTENT: vol.Schema( @@ -149,12 +154,15 @@ async def websocket_run( msg_input = msg["input"] audio_queue: asyncio.Queue[bytes] = asyncio.Queue() incoming_sample_rate = msg_input["sample_rate"] + wake_word_phrase: str | None = None if start_stage == PipelineStage.WAKE_WORD: wake_word_settings = WakeWordSettings( timeout=msg["input"].get("timeout", DEFAULT_WAKE_WORD_TIMEOUT), audio_seconds_to_buffer=msg_input.get("audio_seconds_to_buffer", 0), ) + elif start_stage == PipelineStage.STT: + wake_word_phrase = msg["input"].get("wake_word_phrase") async def stt_stream() -> AsyncGenerator[bytes, None]: state = None @@ -189,6 +197,7 @@ def handle_binary( channel=stt.AudioChannels.CHANNEL_MONO, ) input_args["stt_stream"] = stt_stream() + input_args["wake_word_phrase"] = wake_word_phrase # Audio settings audio_settings = AudioSettings( @@ -241,7 +250,7 @@ def handle_binary( # Task contains a timeout async with asyncio.timeout(timeout): await run_task - except asyncio.TimeoutError: + except TimeoutError: pipeline_input.run.process_event( PipelineEvent( PipelineEventType.ERROR, @@ -487,7 +496,7 @@ def clean_up_queue() -> None: ) try: - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(timeout_seconds): while True: # Send audio chunks encoded as base64 diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py index e4c80a5848d7f5..7fa1e1f14da63a 100644 --- a/homeassistant/components/asterisk_mbox/__init__.py +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_connect +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -50,6 +51,21 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: password: str = conf[CONF_PASSWORD] hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config) + create_issue( + hass, + DOMAIN, + "deprecated_integration", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Asterisk Voicemail", + "mailbox": "mailbox", + }, + ) return True diff --git a/homeassistant/components/asterisk_mbox/strings.json b/homeassistant/components/asterisk_mbox/strings.json new file mode 100644 index 00000000000000..fb6c0637a644b6 --- /dev/null +++ b/homeassistant/components/asterisk_mbox/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_integration": { + "title": "The {integration_title} is being removed", + "description": "{integration_title} is being removed as the `{mailbox}` platform is being removed and {integration_title} supports no other platforms. Remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index cc06c225d22aff..cb04ccdec3f421 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -211,10 +211,7 @@ async def async_disconnect(self) -> None: async def async_get_connected_devices(self) -> dict[str, WrtDevice]: """Get list of connected devices.""" - try: - api_devices = await self._api.async_get_connected_devices() - except OSError as exc: - raise UpdateFailed(exc) from exc + api_devices = await self._api.async_get_connected_devices() return { format_mac(mac): WrtDevice(dev.ip, dev.name, None) for mac, dev in api_devices.items() @@ -343,10 +340,7 @@ async def async_disconnect(self) -> None: async def async_get_connected_devices(self) -> dict[str, WrtDevice]: """Get list of connected devices.""" - try: - api_devices = await self._api.async_get_connected_devices() - except AsusWrtError as exc: - raise UpdateFailed(exc) from exc + api_devices = await self._api.async_get_connected_devices() return { format_mac(mac): WrtDevice(dev.ip, dev.name, dev.node) for mac, dev in api_devices.items() diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 047e9b549d8221..1e320bdd72d23b 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -216,7 +216,7 @@ async def _async_check_connection( if error is not None: return error, None - _LOGGER.info( + _LOGGER.debug( "Successfully connected to the AsusWrt router at %s using protocol %s", host, protocol, diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 927eef572f7186..d868065be47bb4 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -20,7 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util, slugify from .bridge import AsusWrtBridge, WrtDevice @@ -276,7 +276,7 @@ async def update_devices(self) -> None: _LOGGER.debug("Checking devices for ASUS router %s", self.host) try: wrt_devices = await self._api.async_get_connected_devices() - except UpdateFailed as exc: + except (OSError, AsusWrtError) as exc: if not self._connect_error: self._connect_error = True _LOGGER.error( diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index ea27b58d34c8c6..fe16819bf9ca83 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -59,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await async_setup_august(hass, entry, august_gateway) except (RequireValidation, InvalidAuth) as err: raise ConfigEntryAuthFailed from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady("Timed out connecting to august api") from err except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err: raise ConfigEntryNotReady from err @@ -233,7 +233,7 @@ async def _async_initial_sync(self) -> None: return_exceptions=True, ): if isinstance(result, Exception) and not isinstance( - result, (asyncio.TimeoutError, ClientResponseError, CannotConnect) + result, (TimeoutError, ClientResponseError, CannotConnect) ): _LOGGER.warning( "Unexpected exception during initial sync: %s", @@ -293,7 +293,7 @@ async def _async_refresh_device_detail_by_ids( for device_id in device_ids_list: try: await self._async_refresh_device_detail_by_id(device_id) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out calling august api during refresh of device: %s", device_id, diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index fb87a1f7969526..9a41d9bad81b2d 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,7 +1,6 @@ """Consume the august activity stream.""" from __future__ import annotations -import asyncio from datetime import datetime from functools import partial import logging @@ -63,11 +62,10 @@ def __init__( self._update_debounce: dict[str, Debouncer] = {} self._update_debounce_jobs: dict[str, HassJob] = {} - async def _async_update_house_id_later( - self, debouncer: Debouncer, _: datetime - ) -> None: + @callback + def _async_update_house_id_later(self, debouncer: Debouncer, _: datetime) -> None: """Call a debouncer from async_call_later.""" - await debouncer.async_call() + debouncer.async_schedule_call() async def async_setup(self) -> None: """Token refresh check and catch up the activity stream.""" @@ -128,9 +126,9 @@ async def _async_refresh(self, time: datetime) -> None: _LOGGER.debug("Skipping update because pubnub is connected") return _LOGGER.debug("Start retrieving device activities") - await asyncio.gather( - *(debouncer.async_call() for debouncer in self._update_debounce.values()) - ) + # Await in sequence to avoid hammering the API + for debouncer in self._update_debounce.values(): + await debouncer.async_call() @callback def async_schedule_house_id_refresh(self, house_id: str) -> None: @@ -139,7 +137,7 @@ def async_schedule_house_id_refresh(self, house_id: str) -> None: _async_cancel_future_scheduled_updates(future_updates) debouncer = self._update_debounce[house_id] - self._hass.async_create_task(debouncer.async_call()) + debouncer.async_schedule_call() # Schedule two updates past the debounce time # to ensure we catch the case where the activity # api does not update right away and we need to poll diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a1a7adb4ede9e6..ec4eb77605cc94 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -26,7 +26,8 @@ } ], "documentation": "https://www.home-assistant.io/integrations/august", + "import_executor": true, "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"] + "requirements": ["yalexs==1.11.4", "yalexs-ble==2.4.2"] } diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 9b4e118b83eaf0..f2096506c4a4c1 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -43,12 +43,17 @@ def _unsubscribe() -> None: async def _async_refresh(self, time: datetime) -> None: """Refresh data.""" + @callback + def _async_scheduled_refresh(self, now: datetime) -> None: + """Call the refresh method.""" + self._hass.async_create_task(self._async_refresh(now), eager_start=True) + @callback def _async_setup_listeners(self) -> None: """Create interval and stop listeners.""" self._unsub_interval = async_track_time_interval( self._hass, - self._async_refresh, + self._async_scheduled_refresh, self._update_interval, name="august refresh", ) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index f97647fff0e039..dd07e137e5ecf6 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -578,6 +578,7 @@ def websocket_refresh_tokens( connection.send_result(msg["id"], tokens) +@callback @websocket_api.websocket_command( { vol.Required("type"): "auth/delete_refresh_token", @@ -585,8 +586,7 @@ def websocket_refresh_tokens( } ) @websocket_api.ws_require_user() -@websocket_api.async_response -async def websocket_delete_refresh_token( +def websocket_delete_refresh_token( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle a delete refresh token request.""" @@ -601,6 +601,7 @@ async def websocket_delete_refresh_token( connection.send_result(msg["id"], {}) +@callback @websocket_api.websocket_command( { vol.Required("type"): "auth/delete_all_refresh_tokens", @@ -609,8 +610,7 @@ async def websocket_delete_refresh_token( } ) @websocket_api.ws_require_user() -@websocket_api.async_response -async def websocket_delete_all_refresh_tokens( +def websocket_delete_all_refresh_tokens( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete all refresh tokens request.""" diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index e2614af6a3efba..cf7f38fa32a413 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,7 +1,6 @@ """Helpers to resolve client ID/secret.""" from __future__ import annotations -import asyncio from html.parser import HTMLParser from ipaddress import ip_address import logging @@ -102,7 +101,7 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]: if chunks == 10: break - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout while looking up redirect_uri %s", url) except aiohttp.client_exceptions.ClientSSLError: _LOGGER.error("SSL error while looking up redirect_uri %s", url) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index dbf76a1fe5908f..98ec92a3771364 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -701,7 +701,7 @@ async def async_will_remove_from_hass(self) -> None: await super().async_will_remove_from_hass() await self.async_disable() - async def _async_enable_automation(self, event: Event) -> None: + async def _async_enable_automation(self) -> None: """Start automation on startup.""" # Don't do anything if no longer enabled or already attached if not self._is_enabled or self._async_detach_triggers is not None: @@ -709,6 +709,11 @@ async def _async_enable_automation(self, event: Event) -> None: self._async_detach_triggers = await self._async_attach_triggers(True) + @callback + def _async_create_enable_automation_task(self, event: Event) -> None: + """Create a task to enable the automation.""" + self.hass.async_create_task(self._async_enable_automation(), eager_start=True) + async def async_enable(self) -> None: """Enable this automation entity. @@ -726,7 +731,7 @@ async def async_enable(self) -> None: return self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation + EVENT_HOMEASSISTANT_STARTED, self._async_create_enable_automation_task ) self.async_write_ha_state() diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ff0fe43ea26ad7..72fb0101b2410c 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -1,7 +1,6 @@ """Config validation helper for the automation integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from contextlib import suppress from typing import Any @@ -255,15 +254,15 @@ async def async_validate_config_item( async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> ConfigType: """Validate config.""" + # No gather here since _try_async_validate_config_item is unlikely to suspend + # and the cost of creating many tasks is not worth the benefit. automations = list( filter( lambda x: x is not None, - await asyncio.gather( - *( - _try_async_validate_config_item(hass, p_config) - for _, p_config in config_per_platform(config, DOMAIN) - ) - ), + [ + await _try_async_validate_config_item(hass, p_config) + for _, p_config in config_per_platform(config, DOMAIN) + ], ) ) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index 65a425fa5c49fa..ae5ffcbdb7acaa 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -50,7 +50,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if config_entry.version != 3: # Home Assistant 2023.2 - config_entry.version = 3 + hass.config_entries.async_update_entry(config_entry, version=3) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index d68de7742dc7c7..8e7cda335e6a76 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -5,6 +5,10 @@ from datetime import datetime, timedelta from axis.models.event import Event, EventGroup, EventOperation, EventTopic +from axis.vapix.interfaces.applications.fence_guard import FenceGuardHandler +from axis.vapix.interfaces.applications.loitering_guard import LoiteringGuardHandler +from axis.vapix.interfaces.applications.motion_guard import MotionGuardHandler +from axis.vapix.interfaces.applications.vmd4 import Vmd4Handler from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -111,17 +115,33 @@ def _set_name(self, event: Event) -> None: self._attr_name = self.device.api.vapix.ports[event.id].name elif event.group == EventGroup.MOTION: - for event_topic, event_data in ( - (EventTopic.FENCE_GUARD, self.device.api.vapix.fence_guard), - (EventTopic.LOITERING_GUARD, self.device.api.vapix.loitering_guard), - (EventTopic.MOTION_GUARD, self.device.api.vapix.motion_guard), - (EventTopic.OBJECT_ANALYTICS, self.device.api.vapix.object_analytics), - (EventTopic.MOTION_DETECTION_4, self.device.api.vapix.vmd4), + event_data: FenceGuardHandler | LoiteringGuardHandler | MotionGuardHandler | Vmd4Handler | None = None + if event.topic_base == EventTopic.FENCE_GUARD: + event_data = self.device.api.vapix.fence_guard + elif event.topic_base == EventTopic.LOITERING_GUARD: + event_data = self.device.api.vapix.loitering_guard + elif event.topic_base == EventTopic.MOTION_GUARD: + event_data = self.device.api.vapix.motion_guard + elif event.topic_base == EventTopic.MOTION_DETECTION_4: + event_data = self.device.api.vapix.vmd4 + if ( + event_data + and event_data.initialized + and (profiles := event_data["0"].profiles) ): - if ( - event.topic_base == event_topic - and event_data - and event.id in event_data - ): - self._attr_name = f"{self._event_type} {event_data[event.id].name}" - break + for profile_id, profile in profiles.items(): + camera_id = profile.camera + if event.id == f"Camera{camera_id}Profile{profile_id}": + self._attr_name = f"{self._event_type} {profile.name}" + return + + if ( + event.topic_base == EventTopic.OBJECT_ANALYTICS + and self.device.api.vapix.object_analytics.initialized + and (scenarios := self.device.api.vapix.object_analytics["0"].scenarios) + ): + for scenario_id, scenario in scenarios.items(): + device_id = scenario.devices[0]["id"] + if event.id == f"Device{device_id}Scenario{scenario_id}": + self._attr_name = f"{self._event_type} {scenario.name}" + break diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 0b3a93f24fce76..a0c71f101caa37 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -24,7 +24,10 @@ async def async_setup_entry( device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] - if not device.api.vapix.params.image_format: + if ( + not (prop := device.api.vapix.params.property_handler.get("0")) + or not prop.image_format + ): return async_add_entities([AxisCamera(device)]) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 75354bb988414b..cbba23b8b510a4 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -249,7 +249,10 @@ async def async_step_configure_stream( # Stream profiles - if vapix.stream_profiles or vapix.params.stream_profiles_max_groups > 0: + if vapix.stream_profiles or ( + (profiles := vapix.params.stream_profile_handler.get("0")) + and profiles.max_groups > 0 + ): stream_profiles = [DEFAULT_STREAM_PROFILE] for profile in vapix.streaming_profiles: stream_profiles.append(profile.name) @@ -262,14 +265,17 @@ async def async_step_configure_stream( # Video sources - if vapix.params.image_nbrofviews > 0: - await vapix.params.update_image() - - video_sources = {DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE} - for idx, video_source in vapix.params.image_sources.items(): - if not video_source["Enabled"]: + if ( + properties := vapix.params.property_handler.get("0") + ) and properties.image_number_of_views > 0: + await vapix.params.image_handler.update() + video_sources: dict[int | str, str] = { + DEFAULT_VIDEO_SOURCE: DEFAULT_VIDEO_SOURCE + } + for idx, video_source in vapix.params.image_handler.items(): + if not video_source.enabled: continue - video_sources[idx + 1] = video_source["Name"] + video_sources[int(idx) + 1] = video_source.name schema[ vol.Optional(CONF_VIDEO_SOURCE, default=self.device.option_video_source) diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 67ef61af8ac7b1..845487b79d73b0 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -1,6 +1,5 @@ """Axis network device abstraction.""" -import asyncio from asyncio import timeout from types import MappingProxyType from typing import Any @@ -10,6 +9,7 @@ from axis.errors import Unauthorized from axis.stream_manager import Signal, State from axis.vapix.interfaces.mqtt import mqtt_json_to_event +from axis.vapix.models.mqtt import ClientState from homeassistant.components import mqtt from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN @@ -189,9 +189,8 @@ async def async_use_mqtt(self, hass: HomeAssistant, component: str) -> None: status = await self.api.vapix.mqtt.get_client_status() except Unauthorized: # This means the user has too low privileges - status = {} - - if status.get("data", {}).get("status", {}).get("state") == "active": + return + if status.status.state == ClientState.ACTIVE: self.config_entry.async_on_unload( await mqtt.async_subscribe( hass, f"{self.api.vapix.serial_number}/#", self.mqtt_message @@ -210,7 +209,6 @@ def mqtt_message(self, message: ReceiveMessage) -> None: def async_setup_events(self) -> None: """Set up the device events.""" - if self.option_events: self.api.stream.connection_status_callback.append( self.async_connection_status_callback @@ -218,7 +216,7 @@ def async_setup_events(self) -> None: self.api.enable_events() self.api.stream.start() - if self.api.vapix.mqtt: + if self.api.vapix.mqtt.supported: async_when_setup(self.hass, MQTT_DOMAIN, self.async_use_mqtt) @callback @@ -270,7 +268,7 @@ async def get_axis_device( ) raise AuthenticationRequired from err - except (asyncio.TimeoutError, axis.RequestError) as err: + except (TimeoutError, axis.RequestError) as err: LOGGER.error("Error connecting to the Axis device at %s", config[CONF_HOST]) raise CannotConnect from err diff --git a/homeassistant/components/axis/diagnostics.py b/homeassistant/components/axis/diagnostics.py index 20dfedd717b346..948a36a78a0d49 100644 --- a/homeassistant/components/axis/diagnostics.py +++ b/homeassistant/components/axis/diagnostics.py @@ -33,13 +33,13 @@ async def async_get_config_entry_diagnostics( if device.api.vapix.basic_device_info: diag["basic_device_info"] = async_redact_data( - {attr.id: attr.raw for attr in device.api.vapix.basic_device_info.values()}, + device.api.vapix.basic_device_info["0"], REDACT_BASIC_DEVICE_INFO, ) if device.api.vapix.params: diag["params"] = async_redact_data( - {param.id: param.raw for param in device.api.vapix.params.values()}, + device.api.vapix.params.items(), REDACT_VAPIX_PARAMS, ) diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py index 10dc8258d7e49a..cebd2f1206b4e4 100644 --- a/homeassistant/components/axis/light.py +++ b/homeassistant/components/axis/light.py @@ -69,12 +69,12 @@ async def async_added_to_hass(self) -> None: self._light_id ) ) - self.current_intensity = current_intensity["data"]["intensity"] + self.current_intensity = current_intensity max_intensity = await self.device.api.vapix.light_control.get_valid_intensity( self._light_id ) - self.max_intensity = max_intensity["data"]["ranges"][0]["high"] + self.max_intensity = max_intensity.high @callback def async_event_callback(self, event: Event) -> None: @@ -110,4 +110,4 @@ async def async_update(self) -> None: self._light_id ) ) - self.current_intensity = current_intensity["data"]["intensity"] + self.current_intensity = current_intensity diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 296a3da8b663ae..5311d18f991370 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==48"], + "requirements": ["axis==50"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index adcd1ba552532d..c495dfbdc4319f 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -39,7 +39,6 @@ class AxisSwitch(AxisEventEntity, SwitchEntity): def __init__(self, event: Event, device: AxisNetworkDevice) -> None: """Initialize the Axis switch.""" super().__init__(event, device) - if event.id and device.api.vapix.ports[event.id].name: self._attr_name = device.api.vapix.ports[event.id].name self._attr_is_on = event.is_tripped @@ -52,8 +51,8 @@ def async_event_callback(self, event: Event) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self.device.api.vapix.ports[self._event_id].close() + await self.device.api.vapix.ports.close(self._event_id) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self.device.api.vapix.ports[self._event_id].open() + await self.device.api.vapix.ports.open(self._event_id) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8ce8bee779366c..8f19436fb1d18e 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -14,23 +14,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" - if is_hassio(hass): - LOGGER.error( - "The backup integration is not supported on this installation method, " - "please remove it from your configuration" - ) - return False - backup_manager = BackupManager(hass) hass.data[DOMAIN] = backup_manager + with_hassio = is_hassio(hass) + + async_register_websocket_handlers(hass, with_hassio) + + if with_hassio: + if DOMAIN in config: + LOGGER.error( + "The backup integration is not supported on this installation method, " + "please remove it from your configuration" + ) + return True + async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" await backup_manager.generate_backup() hass.services.async_register(DOMAIN, "create", async_handle_create_service) - async_register_websocket_handlers(hass) async_register_http_views(hass) return True diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index fe0d494a650984..4c06f2171b67b0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,20 +4,21 @@ import asyncio from dataclasses import asdict, dataclass import hashlib +import io import json from pathlib import Path import tarfile from tarfile import TarError -from tempfile import TemporaryDirectory +import time from typing import Any, Protocol, cast from securetar import SecureTarFile, atomic_contents_add from homeassistant.const import __version__ as HAVERSION -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform -from homeassistant.helpers.json import save_json +from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object @@ -64,7 +65,8 @@ def __init__(self, hass: HomeAssistant) -> None: self.loaded_backups = False self.loaded_platforms = False - async def _add_platform( + @callback + def _add_platform( self, hass: HomeAssistant, integration_domain: str, @@ -81,6 +83,38 @@ async def _add_platform( return self.platforms[integration_domain] = platform + async def pre_backup_actions(self) -> None: + """Perform pre backup actions.""" + if not self.loaded_platforms: + await self.load_platforms() + + pre_backup_results = await asyncio.gather( + *( + platform.async_pre_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in pre_backup_results: + if isinstance(result, Exception): + raise result + + async def post_backup_actions(self) -> None: + """Perform post backup actions.""" + if not self.loaded_platforms: + await self.load_platforms() + + post_backup_results = await asyncio.gather( + *( + platform.async_post_backup(self.hass) + for platform in self.platforms.values() + ), + return_exceptions=True, + ) + for result in post_backup_results: + if isinstance(result, Exception): + raise result + async def load_backups(self) -> None: """Load data of stored backup files.""" backups = await self.hass.async_add_executor_job(self._read_backups) @@ -159,22 +193,9 @@ async def generate_backup(self) -> Backup: if self.backing_up: raise HomeAssistantError("Backup already in progress") - if not self.loaded_platforms: - await self.load_platforms() - try: self.backing_up = True - pre_backup_results = await asyncio.gather( - *( - platform.async_pre_backup(self.hass) - for platform in self.platforms.values() - ), - return_exceptions=True, - ) - for result in pre_backup_results: - if isinstance(result, Exception): - raise result - + await self.pre_backup_actions() backup_name = f"Core {HAVERSION}" date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) @@ -207,16 +228,7 @@ async def generate_backup(self) -> Backup: return backup finally: self.backing_up = False - post_backup_results = await asyncio.gather( - *( - platform.async_post_backup(self.hass) - for platform in self.platforms.values() - ), - return_exceptions=True, - ) - for result in post_backup_results: - if isinstance(result, Exception): - raise result + await self.post_backup_actions() def _mkdir_and_generate_backup_contents( self, @@ -228,18 +240,18 @@ def _mkdir_and_generate_backup_contents( LOGGER.debug("Creating backup directory") self.backup_dir.mkdir() - with TemporaryDirectory() as tmp_dir, SecureTarFile( + outer_secure_tarfile = SecureTarFile( tar_file_path, "w", gzip=False, bufsize=BUF_SIZE - ) as tar_file: - tmp_dir_path = Path(tmp_dir) - save_json( - tmp_dir_path.joinpath("./backup.json").as_posix(), - backup_data, - ) - with SecureTarFile( - tmp_dir_path.joinpath("./homeassistant.tar.gz").as_posix(), - "w", - bufsize=BUF_SIZE, + ) + with outer_secure_tarfile as outer_secure_tarfile_tarfile: + raw_bytes = json_bytes(backup_data) + fileobj = io.BytesIO(raw_bytes) + tar_info = tarfile.TarInfo(name="./backup.json") + tar_info.size = len(raw_bytes) + tar_info.mtime = int(time.time()) + outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj) + with outer_secure_tarfile.create_inner_tar( + "./homeassistant.tar.gz", gzip=True ) as core_tar: atomic_contents_add( tar_file=core_tar, @@ -247,7 +259,7 @@ def _mkdir_and_generate_backup_contents( excludes=EXCLUDE_FROM_BACKUP, arcname="data", ) - tar_file.add(tmp_dir_path, arcname=".") + return tar_file_path.stat().st_size diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index fb7e9eff7806dc..be75e4717efeb2 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@home-assistant/core"], "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/backup", + "import_executor": true, "integration_type": "system", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["securetar==2023.3.0"] + "requirements": ["securetar==2024.2.1"] } diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index c203019cca911d..c1eed4294c22a1 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,13 +6,18 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .manager import BackupManager @callback -def async_register_websocket_handlers(hass: HomeAssistant) -> None: +def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: """Register websocket commands.""" + if with_hassio: + websocket_api.async_register_command(hass, handle_backup_end) + websocket_api.async_register_command(hass, handle_backup_start) + return + websocket_api.async_register_command(hass, handle_info) websocket_api.async_register_command(hass, handle_create) websocket_api.async_register_command(hass, handle_remove) @@ -69,3 +74,47 @@ async def handle_create( manager: BackupManager = hass.data[DOMAIN] backup = await manager.generate_backup() connection.send_result(msg["id"], backup) + + +@websocket_api.ws_require_user(only_supervisor=True) +@websocket_api.websocket_command({vol.Required("type"): "backup/start"}) +@websocket_api.async_response +async def handle_backup_start( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Backup start notification.""" + manager: BackupManager = hass.data[DOMAIN] + manager.backing_up = True + LOGGER.debug("Backup start notification") + + try: + await manager.pre_backup_actions() + except Exception as err: # pylint: disable=broad-except + connection.send_error(msg["id"], "pre_backup_actions_failed", str(err)) + return + + connection.send_result(msg["id"]) + + +@websocket_api.ws_require_user(only_supervisor=True) +@websocket_api.websocket_command({vol.Required("type"): "backup/end"}) +@websocket_api.async_response +async def handle_backup_end( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Backup end notification.""" + manager: BackupManager = hass.data[DOMAIN] + manager.backing_up = False + LOGGER.debug("Backup end notification") + + try: + await manager.post_backup_actions() + except Exception as err: # pylint: disable=broad-except + connection.send_error(msg["id"], "post_backup_actions_failed", str(err)) + return + + connection.send_result(msg["id"]) diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index fcc648f4001868..e685ec6dc8c78f 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -1,7 +1,6 @@ """The Big Ass Fans integration.""" from __future__ import annotations -import asyncio from asyncio import timeout from aiobafi6 import Device, Service @@ -42,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Unexpected device found at {ip_address}; expected {entry.unique_id}, found {device.dns_sd_uuid}" ) from ex - except asyncio.TimeoutError as ex: + except TimeoutError as ex: run_future.cancel() raise ConfigEntryNotReady(f"Timed out connecting to {ip_address}") from ex diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 9edb23abcf84b6..0aaf2189c2842a 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -1,7 +1,6 @@ """Config flow for baf.""" from __future__ import annotations -import asyncio from asyncio import timeout import logging from typing import Any @@ -28,7 +27,7 @@ async def async_try_connect(ip_address: str) -> Device: try: async with timeout(RUN_TIMEOUT): await device.async_wait_available() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise CannotConnect from ex finally: run_future.cancel() diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index eadf18f05da45c..1d2cd042918ab1 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.FAN, Platform.LIGHT] KEEP_ALIVE_INTERVAL = timedelta(minutes=1) diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index ec7a9fe484a26b..8584ed2783c19e 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -38,7 +38,6 @@ class BalboaBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" is_on_fn: Callable[[SpaClient], bool] - on_off_icons: tuple[str, str] @dataclass(frozen=True) @@ -48,21 +47,18 @@ class BalboaBinarySensorEntityDescription( """A class that describes Balboa binary sensor entities.""" -FILTER_CYCLE_ICONS = ("mdi:sync", "mdi:sync-off") BINARY_SENSOR_DESCRIPTIONS = ( BalboaBinarySensorEntityDescription( key="Filter1", translation_key="filter_1", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_1_running, - on_off_icons=FILTER_CYCLE_ICONS, ), BalboaBinarySensorEntityDescription( key="Filter2", translation_key="filter_2", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: spa.filter_cycle_2_running, - on_off_icons=FILTER_CYCLE_ICONS, ), ) CIRCULATION_PUMP_DESCRIPTION = BalboaBinarySensorEntityDescription( @@ -70,7 +66,6 @@ class BalboaBinarySensorEntityDescription( translation_key="circ_pump", device_class=BinarySensorDeviceClass.RUNNING, is_on_fn=lambda spa: (pump := spa.circulation_pump) is not None and pump.state > 0, - on_off_icons=("mdi:pump", "mdi:pump-off"), ) @@ -90,9 +85,3 @@ def __init__( def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.entity_description.is_on_fn(self._client) - - @property - def icon(self) -> str | None: - """Return the icon to use in the frontend, if any.""" - icons = self.entity_description.on_off_icons - return icons[0] if self.is_on else icons[1] diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py new file mode 100644 index 00000000000000..f6edc45c342e0a --- /dev/null +++ b/homeassistant/components/balboa/fan.py @@ -0,0 +1,89 @@ +"""Support for Balboa Spa pumps.""" +from __future__ import annotations + +import math +from typing import Any, cast + +from pybalboa import SpaClient, SpaControl +from pybalboa.enums import OffOnState, UnknownState + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from .const import DOMAIN +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the spa's pumps.""" + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] + async_add_entities(BalboaPumpFanEntity(control) for control in spa.pumps) + + +class BalboaPumpFanEntity(BalboaEntity, FanEntity): + """Representation of a Balboa Spa pump fan entity.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_translation_key = "pump" + + def __init__(self, control: SpaControl) -> None: + """Initialize a Balboa pump fan entity.""" + super().__init__(control.client, control.name) + self._control = control + self._attr_translation_placeholders = { + "index": f"{cast(int, control.index) + 1}" + } + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the pump off.""" + await self._control.set_state(OffOnState.OFF) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the pump on (by default on max speed).""" + if percentage is None: + percentage = 100 + await self.async_set_percentage(percentage) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the pump.""" + if percentage > 0: + state = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + else: + state = OffOnState.OFF + await self._control.set_state(state) + + @property + def percentage(self) -> int | None: + """Return the speed of the pump.""" + if self._control.state == UnknownState.UNKNOWN: + return None + if self._control.state == OffOnState.OFF: + return 0 + return ranged_value_to_percentage((1, self.speed_count), self._control.state) + + @property + def is_on(self) -> bool | None: + """Return true if the pump is running.""" + if self._control.state == UnknownState.UNKNOWN: + return None + return self._control.state != OffOnState.OFF + + @property + def speed_count(self) -> int: + """Return the number of different speed settings the pump supports.""" + return int(max(self._control.options)) diff --git a/homeassistant/components/balboa/icons.json b/homeassistant/components/balboa/icons.json new file mode 100644 index 00000000000000..fb1b6d01ed42cf --- /dev/null +++ b/homeassistant/components/balboa/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "binary_sensor": { + "filter_1": { + "default": "mdi:sync-off", + "state": { + "on": "mdi:sync" + } + }, + "filter_2": { + "default": "mdi:sync-off", + "state": { + "on": "mdi:sync" + } + }, + "circ_pump": { + "default": "mdi:pump-off", + "state": { + "on": "mdi:pump" + } + } + }, + "fan": { + "pump": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + } + } +} diff --git a/homeassistant/components/balboa/light.py b/homeassistant/components/balboa/light.py new file mode 100644 index 00000000000000..00b8eb979a2806 --- /dev/null +++ b/homeassistant/components/balboa/light.py @@ -0,0 +1,56 @@ +"""Support for Balboa Spa lights.""" +from __future__ import annotations + +from typing import Any, cast + +from pybalboa import SpaClient, SpaControl +from pybalboa.enums import OffOnState, UnknownState + +from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the spa's lights.""" + spa: SpaClient = hass.data[DOMAIN][entry.entry_id] + async_add_entities(BalboaLightEntity(control) for control in spa.lights) + + +class BalboaLightEntity(BalboaEntity, LightEntity): + """Representation of a Balboa Spa light entity.""" + + _attr_color_mode = ColorMode.ONOFF + _attr_supported_color_modes = {ColorMode.ONOFF} + + def __init__(self, control: SpaControl) -> None: + """Initialize a Balboa Spa light entity.""" + super().__init__(control.client, control.name) + self._control = control + self._attr_translation_key = ( + "light_of_n" if len(control.client.lights) > 1 else "only_light" + ) + self._attr_translation_placeholders = { + "index": f"{cast(int, control.index) + 1}" + } + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self._control.set_state(OffOnState.OFF) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + await self._control.set_state(OffOnState.ON) + + @property + def is_on(self) -> bool | None: + """Return true if the light is on.""" + if self._control.state == UnknownState.UNKNOWN: + return None + return self._control.state != OffOnState.OFF diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index e0af12514da37f..3c8f82764d49e1 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -52,6 +52,19 @@ } } } + }, + "fan": { + "pump": { + "name": "Pump {index}" + } + }, + "light": { + "light_of_n": { + "name": "Light {index}" + }, + "only_light": { + "name": "Light" + } } } } diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 869cabc5a4a13b..eaafddcabd6ff2 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -523,7 +523,6 @@ async def async_select_source(self, source: str) -> None: ) return - # pylint: disable=consider-using-dict-items key = [x for x in self._sources if self._sources[x] == source][0] # Check for source type diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index daa23553c96b56..61dca6550c0a93 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from pyblackbird import get_blackbird from serial import SerialException @@ -93,7 +92,7 @@ def setup_platform( try: blackbird = get_blackbird(host, False) connection = host - except socket.timeout: + except TimeoutError: _LOGGER.error("Error connecting to the Blackbird controller") return diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index c4f13503abf205..6446949cb89ffb 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -62,7 +62,6 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" super().__init__(feature) - self._attr_supported_color_modes = {self.color_mode} if feature.effect_list: self._attr_supported_features = LightEntityFeature.EFFECT @@ -94,6 +93,11 @@ def color_mode(self): return color_mode_tmp + @property + def supported_color_modes(self): + """Return supported color modes.""" + return {self.color_mode} + @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 3eaa6d04ed2256..566935c405f40f 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -1,11 +1,11 @@ { "domain": "blebox", "name": "BleBox devices", - "codeowners": ["@bbx-a", "@riokuu"], + "codeowners": ["@bbx-a", "@riokuu", "@swistakm"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.2.0"], + "requirements": ["blebox-uniapi==2.2.2"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 50c7fad516ac8e..e86d07c8780526 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,5 +1,4 @@ """Support for Blink Home Camera System.""" -import asyncio from copy import deepcopy import logging @@ -93,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await blink.start() - except (ClientError, asyncio.TimeoutError) as ex: + except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Can not connect to host") from ex if blink.auth.check_key_required(): diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 80a6ceb50e063d..f2c01de4f18c6e 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,7 +1,6 @@ """Support for Blink Alarm Control Panel.""" from __future__ import annotations -import asyncio import logging from blinkpy.blinkpy import Blink, BlinkSyncModule @@ -27,8 +26,6 @@ _LOGGER = logging.getLogger(__name__) -ICON = "mdi:security" - async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -47,7 +44,6 @@ class BlinkSyncModuleHA( ): """Representation of a Blink Alarm Control Panel.""" - _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY _attr_has_entity_name = True _attr_name = None @@ -91,7 +87,7 @@ async def async_alarm_disarm(self, code: str | None = None) -> None: try: await self.sync.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er await self.coordinator.async_refresh() @@ -101,7 +97,7 @@ async def async_alarm_arm_away(self, code: str | None = None) -> None: try: await self.sync.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera away") from er await self.coordinator.async_refresh() diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 838020c98c6670..ff4fa6380a7964 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,7 +1,6 @@ """Support for Blink system camera.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import contextlib import logging @@ -96,7 +95,7 @@ async def async_enable_motion_detection(self) -> None: try: await self._camera.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to arm camera") from er self._camera.motion_enabled = True @@ -106,7 +105,7 @@ async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" try: await self._camera.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError("Blink failed to disarm camera") from er self._camera.motion_enabled = False @@ -124,7 +123,7 @@ def brand(self) -> str | None: async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): await self._camera.snap_picture() self.async_write_ha_state() diff --git a/homeassistant/components/blink/icons.json b/homeassistant/components/blink/icons.json new file mode 100644 index 00000000000000..cd8a282737f643 --- /dev/null +++ b/homeassistant/components/blink/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "wifi_strength": { + "default": "mdi:wifi" + } + }, + "switch": { + "camera_motion": { + "default": "mdi:motion-sensor" + } + } + }, + "services": { + "blink_update": "mdi:update", + "trigger_camera": "mdi:image-refresh", + "save_video": "mdi:file-video", + "save_recent_clips": "mdi:file-video", + "send_pin": "mdi:two-factor-authentication" + } +} diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 445a469b1412c9..48db78b572cd5c 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -18,6 +18,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/blink", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["blinkpy"], "requirements": ["blinkpy==0.22.6"] diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index ea31d1b29ab725..fb429e79dc8345 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -32,7 +32,6 @@ SensorEntityDescription( key=TYPE_WIFI_STRENGTH, translation_key="wifi_strength", - icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py index 197c8e086856ee..2b25d1bce0cf29 100644 --- a/homeassistant/components/blink/switch.py +++ b/homeassistant/components/blink/switch.py @@ -1,7 +1,6 @@ """Support for Blink Motion detection switches.""" from __future__ import annotations -import asyncio from typing import Any from homeassistant.components.switch import ( @@ -22,7 +21,6 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key=TYPE_CAMERA_ARMED, - icon="mdi:motion-sensor", translation_key="camera_motion", device_class=SwitchDeviceClass.SWITCH, ), @@ -74,7 +72,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: try: await self._camera.async_arm(True) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError( "Blink failed to arm camera motion detection" ) from er @@ -86,7 +84,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: try: await self._camera.async_arm(False) - except asyncio.TimeoutError as er: + except TimeoutError as er: raise HomeAssistantError( "Blink failed to dis-arm camera motion detection" ) from er diff --git a/homeassistant/components/bliss_automation/__init__.py b/homeassistant/components/bliss_automation/__init__.py new file mode 100644 index 00000000000000..6f223834e71922 --- /dev/null +++ b/homeassistant/components/bliss_automation/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Bliss automation.""" diff --git a/homeassistant/components/bloc_blinds/__init__.py b/homeassistant/components/bloc_blinds/__init__.py new file mode 100644 index 00000000000000..9c8f23a3658ff6 --- /dev/null +++ b/homeassistant/components/bloc_blinds/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Bloc_blinds.""" diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 604f251bfeb6b7..16b81c3c1e79be 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -1,6 +1,7 @@ """The Blue Current integration.""" from __future__ import annotations +import asyncio from contextlib import suppress from datetime import datetime from typing import Any @@ -14,8 +15,13 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + ATTR_NAME, + CONF_API_TOKEN, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -47,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except BlueCurrentException as err: raise ConfigEntryNotReady from err - hass.async_create_task(connector.start_loop()) + hass.async_create_background_task(connector.start_loop(), "blue_current-websocket") await client.get_charge_points() await client.wait_for_response() @@ -56,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.async_on_unload(connector.disconnect) + async def _async_disconnect_websocket(_: Event) -> None: + await connector.disconnect() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_websocket) + return True @@ -78,9 +89,9 @@ def __init__( self, hass: HomeAssistant, config: ConfigEntry, client: Client ) -> None: """Initialize.""" - self.config: ConfigEntry = config - self.hass: HomeAssistant = hass - self.client: Client = client + self.config = config + self.hass = hass + self.client = client self.charge_points: dict[str, dict] = {} self.grid: dict[str, Any] = {} self.available = False @@ -93,22 +104,12 @@ async def connect(self, token: str) -> None: async def on_data(self, message: dict) -> None: """Handle received data.""" - async def handle_charge_points(data: list) -> None: - """Loop over the charge points and get their data.""" - for entry in data: - evse_id = entry[EVSE_ID] - model = entry[MODEL_TYPE] - name = entry[ATTR_NAME] - self.add_charge_point(evse_id, model, name) - await self.get_charge_point_data(evse_id) - await self.client.get_grid_status(data[0][EVSE_ID]) - object_name: str = message[OBJECT] # gets charge point ids if object_name == CHARGE_POINTS: charge_points_data: list = message[DATA] - await handle_charge_points(charge_points_data) + await self.handle_charge_point_data(charge_points_data) # gets charge point key / values elif object_name in VALUE_TYPES: @@ -122,8 +123,21 @@ async def handle_charge_points(data: list) -> None: self.grid = data self.dispatch_grid_update_signal() - async def get_charge_point_data(self, evse_id: str) -> None: - """Get all the data of a charge point.""" + async def handle_charge_point_data(self, charge_points_data: list) -> None: + """Handle incoming chargepoint data.""" + await asyncio.gather( + *( + self.handle_charge_point( + entry[EVSE_ID], entry[MODEL_TYPE], entry[ATTR_NAME] + ) + for entry in charge_points_data + ) + ) + await self.client.get_grid_status(charge_points_data[0][EVSE_ID]) + + async def handle_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add the chargepoint and request their data.""" + self.add_charge_point(evse_id, model, name) await self.client.get_status(evse_id) def add_charge_point(self, evse_id: str, model: str, name: str) -> None: @@ -159,9 +173,8 @@ async def reconnect(self, _event_time: datetime | None = None) -> None: """Keep trying to reconnect to the websocket.""" try: await self.connect(self.config.data[CONF_API_TOKEN]) - LOGGER.info("Reconnected to the Blue Current websocket") + LOGGER.debug("Reconnected to the Blue Current websocket") self.hass.async_create_task(self.start_loop()) - await self.client.get_charge_points() except RequestLimitReached: self.available = False async_call_later( diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py index 300f2191cdc91b..c797fec08b0f2b 100644 --- a/homeassistant/components/blue_current/entity.py +++ b/homeassistant/components/blue_current/entity.py @@ -1,4 +1,6 @@ """Entity representing a Blue Current charge point.""" +from abc import abstractmethod + from homeassistant.const import ATTR_NAME from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo @@ -17,9 +19,9 @@ class BlueCurrentEntity(Entity): def __init__(self, connector: Connector, signal: str) -> None: """Initialize the entity.""" - self.connector: Connector = connector - self.signal: str = signal - self.has_value: bool = False + self.connector = connector + self.signal = signal + self.has_value = False async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -40,9 +42,9 @@ def available(self) -> bool: return self.connector.available and self.has_value @callback + @abstractmethod def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - raise NotImplementedError class ChargepointEntity(BlueCurrentEntity): @@ -50,6 +52,8 @@ class ChargepointEntity(BlueCurrentEntity): def __init__(self, connector: Connector, evse_id: str) -> None: """Initialize the entity.""" + super().__init__(connector, f"{DOMAIN}_value_update_{evse_id}") + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] self.evse_id = evse_id @@ -59,5 +63,3 @@ def __init__(self, connector: Connector, evse_id: str) -> None: manufacturer="Blue Current", model=connector.charge_points[evse_id][MODEL_TYPE], ) - - super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/icons.json b/homeassistant/components/blue_current/icons.json new file mode 100644 index 00000000000000..b5a5f2be81ea11 --- /dev/null +++ b/homeassistant/components/blue_current/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "vehicle_status": { + "default": "mdi:car" + }, + "activity": { + "default": "mdi:ev-station" + }, + "max_usage": { + "default": "mdi:gauge-full" + }, + "smartcharging_max_usage": { + "default": "mdi:gauge-full" + }, + "max_offline": { + "default": "mdi:gauge-full" + }, + "current_left": { + "default": "mdi:gauge" + } + } + } +} diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py index 326caa70f54914..02a40e09089fed 100644 --- a/homeassistant/components/blue_current/sensor.py +++ b/homeassistant/components/blue_current/sensor.py @@ -124,14 +124,12 @@ ), SensorEntityDescription( key="vehicle_status", - icon="mdi:car", device_class=SensorDeviceClass.ENUM, options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], translation_key="vehicle_status", ), SensorEntityDescription( key="activity", - icon="mdi:ev-station", device_class=SensorDeviceClass.ENUM, options=["available", "charging", "unavailable", "error", "offline"], translation_key="activity", @@ -139,7 +137,6 @@ SensorEntityDescription( key="max_usage", translation_key="max_usage", - icon="mdi:gauge-full", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -147,7 +144,6 @@ SensorEntityDescription( key="smartcharging_max_usage", translation_key="smartcharging_max_usage", - icon="mdi:gauge-full", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, @@ -156,7 +152,6 @@ SensorEntityDescription( key="max_offline", translation_key="max_offline", - icon="mdi:gauge-full", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, @@ -165,7 +160,6 @@ SensorEntityDescription( key="current_left", translation_key="current_left", - icon="mdi:gauge", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, device_class=SensorDeviceClass.CURRENT, diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json index 293d0cd6ab783b..3ba6349b714f60 100644 --- a/homeassistant/components/blue_current/strings.json +++ b/homeassistant/components/blue_current/strings.json @@ -13,7 +13,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "limit_reached": "Request limit reached", "invalid_token": "Invalid token", - "no_cards_found": "No charge cards found", "already_connected": "Already connected", "unknown": "[%key:common::config_flow::error::unknown%]" }, diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index eba03963ebc492..70c19b5fa6ff87 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -290,7 +290,7 @@ async def _start_poll_command(self): while True: await self.async_update_status() - except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): + except (TimeoutError, ClientError, BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s:%s is offline, retrying later", self.name, self.port) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() @@ -317,7 +317,7 @@ async def async_init(self, triggered=None): self._retry_remove = None await self.force_update_sync_status(self._init_callback, True) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): _LOGGER.info("Node %s:%s is offline, retrying later", self.host, self.port) self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION @@ -370,7 +370,7 @@ async def send_bluesound_command( _LOGGER.error("Error %s on %s", response.status, url) return None - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): if raise_timeout: _LOGGER.info("Timeout: %s:%s", self.host, self.port) raise @@ -437,7 +437,7 @@ async def async_update_status(self): "Error %s on %s. Trying one more time", response.status, url ) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2dd4f06ecdff2a..c2f1724b34049f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -16,6 +16,7 @@ DEFAULT_ADDRESS, DEFAULT_CONNECTION_SLOTS, AdapterDetails, + BluetoothAdapters, adapter_human_name, adapter_model, adapter_unique_name, @@ -135,27 +136,13 @@ async def _async_get_adapter_from_address( return await _get_manager(hass).async_get_adapter_from_address(address) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the bluetooth integration.""" - await passive_update_processor.async_setup(hass) - integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) - integration_matcher.async_setup() - bluetooth_adapters = get_adapters() - bluetooth_storage = BluetoothStorage(hass) - await bluetooth_storage.async_setup() - slot_manager = BleakSlotManager() - await slot_manager.async_setup() - manager = HomeAssistantBluetoothManager( - hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager - ) - set_manager(manager) - await manager.async_setup() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() - ) - hass.data[DATA_MANAGER] = models.MANAGER = manager +async def _async_start_adapter_discovery( + hass: HomeAssistant, + manager: HomeAssistantBluetoothManager, + bluetooth_adapters: BluetoothAdapters, +) -> None: + """Start adapter discovery.""" adapters = await manager.async_get_bluetooth_adapters() - async_migrate_entries(hass, adapters, bluetooth_adapters.default_adapter) await async_discover_adapters(hass, adapters) @@ -173,9 +160,10 @@ async def _async_rediscover_adapters() -> None: function=_async_rediscover_adapters, ) - async def _async_shutdown_debouncer(_: Event) -> None: + @hass_callback + def _async_shutdown_debouncer(_: Event) -> None: """Shutdown debouncer.""" - await discovery_debouncer.async_shutdown() + discovery_debouncer.async_shutdown() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_debouncer) @@ -211,7 +199,42 @@ def _async_trigger_discovery() -> None: EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel()) ) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the bluetooth integration.""" + bluetooth_adapters = get_adapters() + bluetooth_storage = BluetoothStorage(hass) + slot_manager = BleakSlotManager() + integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) + + slot_manager_setup_task = hass.async_create_task( + slot_manager.async_setup(), "slot_manager setup", eager_start=True + ) + processor_setup_task = hass.async_create_task( + passive_update_processor.async_setup(hass), + "passive_update_processor setup", + eager_start=True, + ) + storage_setup_task = hass.async_create_task( + bluetooth_storage.async_setup(), "bluetooth storage setup", eager_start=True + ) + integration_matcher.async_setup() + manager = HomeAssistantBluetoothManager( + hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager + ) + set_manager(manager) + + await storage_setup_task + await manager.async_setup() + hass.data[DATA_MANAGER] = models.MANAGER = manager + + hass.async_create_background_task( + _async_start_adapter_discovery(hass, manager, bluetooth_adapters), + "start_adapter_discovery", + ) + await slot_manager_setup_task async_delete_issue(hass, DOMAIN, "haos_outdated") + await processor_setup_task return True diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 174e5c66ce8512..cf8590079bc7c7 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -168,7 +168,7 @@ def _async_handle_bluetooth_event( # We use bluetooth events to trigger the poll so that we scan as soon as # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): - self.hass.async_create_task(self._debounced_poll.async_call()) + self._debounced_poll.async_schedule_call() @callback def _async_stop(self) -> None: diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 3a13dda28a8a6d..d0be6c618119d2 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -157,7 +157,7 @@ def _async_handle_bluetooth_event( # We use bluetooth events to trigger the poll so that we scan as soon as # possible after a device comes online or back in range, if a poll is due if self.needs_poll(service_info): - self.hass.async_create_task(self._debounced_poll.async_call()) + self._debounced_poll.async_schedule_call() @callback def _async_stop(self) -> None: diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 381beb02520ea4..32589d822d3b6a 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -11,7 +11,7 @@ from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager from homeassistant import config_entries -from homeassistant.const import EVENT_LOGGING_CHANGED +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -136,6 +136,7 @@ async def async_setup(self) -> None: self._cancel_logging_listener = self.hass.bus.async_listen( EVENT_LOGGING_CHANGED, self._async_logging_changed ) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) seen: set[str] = set() for address, service_info in itertools.chain( self._connectable_history.items(), self._all_history.items() @@ -187,7 +188,7 @@ def _async_remove_callback() -> None: return _async_remove_callback @hass_callback - def async_stop(self) -> None: + def async_stop(self, event: Event | None = None) -> None: """Stop the Bluetooth integration at shutdown.""" _LOGGER.debug("Stopping bluetooth manager") self._async_save_scanner_histories() diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index a0a61c14e8a8f0..b8158a06f7eaad 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", + "import_executor": true, "iot_class": "local_push", "loggers": [ "btsocket", @@ -16,10 +17,10 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.4.0", - "bluetooth-adapters==0.17.0", + "bluetooth-adapters==0.18.0", "bluetooth-auto-recovery==1.3.0", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", - "habluetooth==2.4.0" + "habluetooth==2.4.2" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 453ab996abc8a3..2fd650d95808c7 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -90,6 +90,8 @@ def seen_all_fields( class IntegrationMatcher: """Integration matcher for the bluetooth integration.""" + __slots__ = ("_integration_matchers", "_matched", "_matched_connectable", "_index") + def __init__(self, integration_matchers: list[BluetoothMatcher]) -> None: """Initialize the matcher.""" self._integration_matchers = integration_matchers @@ -159,6 +161,16 @@ class BluetoothMatcherIndexBase(Generic[_T]): any bucket and we can quickly reject the service info as not matching. """ + __slots__ = ( + "local_name", + "service_uuid", + "service_data_uuid", + "manufacturer_id", + "service_uuid_set", + "service_data_uuid_set", + "manufacturer_id_set", + ) + def __init__(self) -> None: """Initialize the matcher index.""" self.local_name: dict[str, list[_T]] = {} @@ -285,6 +297,8 @@ class BluetoothCallbackMatcherIndex( Supports matching on addresses. """ + __slots__ = ("address", "connectable") + def __init__(self) -> None: """Initialize the matcher index.""" super().__init__() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 601f78d4c8d190..a92a5317ba46e5 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -51,6 +51,7 @@ class PassiveBluetoothEntityKey: Example: key: temperature device_id: outdoor_sensor_1 + """ key: str @@ -648,7 +649,8 @@ def __init__( self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name if device_id is None: self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} - self._attr_name = processor.entity_names.get(entity_key) + if (name := processor.entity_names.get(entity_key)) is not None: + self._attr_name = name @property def available(self) -> bool: diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 3739734223e72e..f85a9506d7233d 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -1,7 +1,6 @@ """Tracking for bluetooth low energy devices.""" from __future__ import annotations -import asyncio from datetime import datetime, timedelta import logging from uuid import UUID @@ -155,7 +154,7 @@ async def _async_see_update_ble_battery( async with BleakClient(device) as client: bat_char = await client.read_gatt_char(BATTERY_CHARACTERISTIC_UUID) battery = ord(bat_char) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug( "Timeout when trying to get battery status for %s", service_info.name ) diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index d5a213256c3b19..079563b1ad335c 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -10,7 +10,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_NAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import discovery, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + discovery, + entity_registry as er, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.typing import ConfigType @@ -146,6 +150,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) + # Clean up vehicles which are not assigned to the account anymore + account_vehicles = {(DOMAIN, v.vin) for v in coordinator.account.vehicles} + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + for device in device_entries: + if not device.identifiers.intersection(account_vehicles): + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + return True diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 29c4d61e9f7192..7ff9ad2d8ab9f8 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -130,7 +130,6 @@ class BMWBinarySensorEntityDescription( key="lids", translation_key="lids", device_class=BinarySensorDeviceClass.OPENING, - icon="mdi:car-door-lock", # device class opening: On means open, Off means closed value_fn=lambda v: not v.doors_and_windows.all_lids_closed, attr_fn=lambda v, u: { @@ -141,7 +140,6 @@ class BMWBinarySensorEntityDescription( key="windows", translation_key="windows", device_class=BinarySensorDeviceClass.OPENING, - icon="mdi:car-door", # device class opening: On means open, Off means closed value_fn=lambda v: not v.doors_and_windows.all_windows_closed, attr_fn=lambda v, u: { @@ -152,7 +150,6 @@ class BMWBinarySensorEntityDescription( key="door_lock_state", translation_key="door_lock_state", device_class=BinarySensorDeviceClass.LOCK, - icon="mdi:car-key", # device class lock: On means unlocked, Off means locked # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED value_fn=lambda v: v.doors_and_windows.door_lock_state @@ -165,7 +162,6 @@ class BMWBinarySensorEntityDescription( key="condition_based_services", translation_key="condition_based_services", device_class=BinarySensorDeviceClass.PROBLEM, - icon="mdi:wrench", # device class problem: On means problem detected, Off means no problem value_fn=lambda v: v.condition_based_services.is_service_required, attr_fn=_condition_based_services, @@ -174,7 +170,6 @@ class BMWBinarySensorEntityDescription( key="check_control_messages", translation_key="check_control_messages", device_class=BinarySensorDeviceClass.PROBLEM, - icon="mdi:car-tire-alert", # device class problem: On means problem detected, Off means no problem value_fn=lambda v: v.check_control_messages.has_check_control_messages, attr_fn=lambda v, u: _check_control_messages(v), @@ -184,7 +179,6 @@ class BMWBinarySensorEntityDescription( key="charging_status", translation_key="charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - icon="mdi:ev-station", # device class power: On means power detected, Off means no power value_fn=lambda v: v.fuel_and_battery.charging_status == ChargingState.CHARGING, ), @@ -192,13 +186,11 @@ class BMWBinarySensorEntityDescription( key="connection_status", translation_key="connection_status", device_class=BinarySensorDeviceClass.PLUG, - icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), BMWBinarySensorEntityDescription( key="is_pre_entry_climatization_enabled", translation_key="is_pre_entry_climatization_enabled", - icon="mdi:car-seat-heater", value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled if v.charging_profile else False, diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index f2a123fe4a8af2..74f12c9c721bb9 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -44,24 +44,21 @@ class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): BMWButtonEntityDescription( key="light_flash", translation_key="light_flash", - icon="mdi:car-light-alert", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_light_flash(), ), BMWButtonEntityDescription( key="sound_horn", translation_key="sound_horn", - icon="mdi:bullhorn", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_horn(), ), BMWButtonEntityDescription( key="activate_air_conditioning", translation_key="activate_air_conditioning", - icon="mdi:hvac", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), BMWButtonEntityDescription( key="deactivate_air_conditioning", - icon="mdi:hvac-off", + translation_key="deactivate_air_conditioning", name="Deactivate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), is_available=lambda vehicle: vehicle.is_remote_climate_stop_enabled, @@ -69,7 +66,6 @@ class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): BMWButtonEntityDescription( key="find_vehicle", translation_key="find_vehicle", - icon="mdi:crosshairs-question", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_vehicle_finder(), ), ) diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 12d297361832d5..a97ed1e1092766 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -45,6 +45,7 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): """MyBMW device tracker.""" _attr_force_update = False + _attr_translation_key = "car" _attr_icon = "mdi:car" def __init__( diff --git a/homeassistant/components/bmw_connected_drive/icons.json b/homeassistant/components/bmw_connected_drive/icons.json new file mode 100644 index 00000000000000..a4eb37b369a84d --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/icons.json @@ -0,0 +1,99 @@ +{ + "entity": { + "binary_sensor": { + "lids": { + "default": "mdi:car-door-lock" + }, + "windows": { + "default": "mdi:car-door" + }, + "door_lock_state": { + "default": "mdi:car-key" + }, + "condition_based_services": { + "default": "mdi:wrench" + }, + "check_control_messages": { + "default": "mdi:car-tire-alert" + }, + "charging_status": { + "default": "mdi:ev-station" + }, + "connection_status": { + "default": "mdi:car-electric" + }, + "is_pre_entry_climatization_enabled": { + "default": "mdi:car-seat-heater" + } + }, + "button": { + "light_flash": { + "default": "mdi:car-light-alert" + }, + "sound_horn": { + "default": "mdi:bullhorn" + }, + "activate_air_conditioning": { + "default": "mdi:hvac" + }, + "deactivate_air_conditioning": { + "default": "mdi:hvac-off" + }, + "find_vehicle": { + "default": "mdi:crosshairs-question" + } + }, + "device_tracker": { + "car": { + "default": "mdi:car" + } + }, + "number": { + "target_soc": { + "default": "mdi:battery-charging-medium" + } + }, + "select": { + "ac_limit": { + "default": "mdi:current-ac" + }, + "charging_mode": { + "default": "mdi:vector-point-select" + } + }, + "sensor": { + "charging_status": { + "default": "mdi:ev-station" + }, + "charging_target": { + "default": "mdi:battery-charging-high" + }, + "mileage": { + "default": "mdi:speedometer" + }, + "remaining_range_total": { + "default": "mdi:map-marker-distance" + }, + "remaining_range_electric": { + "default": "mdi:map-marker-distance" + }, + "remaining_range_fuel": { + "default": "mdi:map-marker-distance" + }, + "remaining_fuel": { + "default": "mdi:gas-station" + }, + "remaining_fuel_percent": { + "default": "mdi:gas-station" + } + }, + "switch": { + "climate": { + "default": "mdi:fan" + }, + "charging": { + "default": "mdi:ev-station" + } + } + } +} diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index 0ed732e1dcb966..21326a591187d6 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -56,7 +56,6 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( target_soc=int(o) ), - icon="mdi:battery-charging-medium", ), ] diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 8823c6552cc555..db426b894872f6 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -51,7 +51,6 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update( ac_limit=int(o) ), - icon="mdi:current-ac", unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), "charging_mode": BMWSelectEntityDescription( @@ -63,7 +62,6 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): remote_service=lambda v, o: v.remote_services.trigger_charging_profile_update( charging_mode=ChargingMode(o) ), - icon="mdi:vector-point-select", ), } diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index d486c41ae56ee5..27a5824a7d7138 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -59,7 +59,6 @@ def convert_and_round( translation_key="ac_current_limit", key_class="charging_profile", unit_type=UnitOfElectricCurrent.AMPERE, - icon="mdi:current-ac", entity_registry_enabled_default=False, ), "charging_start_time": BMWSensorEntityDescription( @@ -79,14 +78,12 @@ def convert_and_round( key="charging_status", translation_key="charging_status", key_class="fuel_and_battery", - icon="mdi:ev-station", value=lambda x, y: x.value, ), "charging_target": BMWSensorEntityDescription( key="charging_target", translation_key="charging_target", key_class="fuel_and_battery", - icon="mdi:battery-charging-high", unit_type=PERCENTAGE, ), "remaining_battery_percent": BMWSensorEntityDescription( @@ -101,7 +98,6 @@ def convert_and_round( "mileage": BMWSensorEntityDescription( key="mileage", translation_key="mileage", - icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), state_class=SensorStateClass.TOTAL_INCREASING, @@ -110,7 +106,6 @@ def convert_and_round( key="remaining_range_total", translation_key="remaining_range_total", key_class="fuel_and_battery", - icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), state_class=SensorStateClass.MEASUREMENT, @@ -119,7 +114,6 @@ def convert_and_round( key="remaining_range_electric", translation_key="remaining_range_electric", key_class="fuel_and_battery", - icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), state_class=SensorStateClass.MEASUREMENT, @@ -128,7 +122,6 @@ def convert_and_round( key="remaining_range_fuel", translation_key="remaining_range_fuel", key_class="fuel_and_battery", - icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), state_class=SensorStateClass.MEASUREMENT, @@ -137,7 +130,6 @@ def convert_and_round( key="remaining_fuel", translation_key="remaining_fuel", key_class="fuel_and_battery", - icon="mdi:gas-station", unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), state_class=SensorStateClass.MEASUREMENT, @@ -146,7 +138,6 @@ def convert_and_round( key="remaining_fuel_percent", translation_key="remaining_fuel_percent", key_class="fuel_and_battery", - icon="mdi:gas-station", unit_type=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index e4ce0ba81ff6e7..7c8952f4eccb1c 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -56,7 +56,6 @@ class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): value_fn=lambda v: v.climate.is_climate_on, remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), - icon="mdi:fan", ), BMWSwitchEntityDescription( key="charging", @@ -65,7 +64,6 @@ class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, remote_service_on=lambda v: v.remote_services.trigger_charge_start(), remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), - icon="mdi:ev-station", ), ] diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index b6f402004f6268..2e60512156fafe 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -1,5 +1,4 @@ """The Bond integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError from http import HTTPStatus import logging from typing import Any @@ -56,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Bond token no longer valid: %s", ex) return False raise ConfigEntryNotReady from ex - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error bpup_subs = BPUPSubscriptions() diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 26b485127f2543..33b5d2bf2c4173 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Bond integration.""" from __future__ import annotations -import asyncio import contextlib from http import HTTPStatus import logging @@ -87,7 +86,7 @@ async def _async_try_automatic_configure(self) -> None: try: if not (token := await async_get_token(self.hass, host)): return - except asyncio.TimeoutError: + except TimeoutError: return self._discovered[CONF_ACCESS_TOKEN] = token diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 2c54ad8f3dddd6..dd307547b81939 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from asyncio import Lock, TimeoutError as AsyncIOTimeoutError +from asyncio import Lock from datetime import datetime import logging @@ -139,7 +139,7 @@ async def _async_update_from_api(self) -> None: """Fetch via the API.""" try: state: dict = await self._hub.bond.device_state(self._device_id) - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: if self.available: _LOGGER.warning( "Entity %s has become unavailable", self.entity_id, exc_info=error diff --git a/homeassistant/components/bosch_shc/icons.json b/homeassistant/components/bosch_shc/icons.json new file mode 100644 index 00000000000000..0b1cb767054403 --- /dev/null +++ b/homeassistant/components/bosch_shc/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "sensor": { + "purity": { + "default": "mdi:molecule-co2" + }, + "communication_quality": { + "default": "mdi:wifi" + }, + "valvetappet": { + "default": "mdi:gauge" + } + }, + "switch": { + "routing": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index df216ed0ff2dd3..c9c194bdc08345 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -199,7 +199,6 @@ class PuritySensor(SHCEntity, SensorEntity): """Representation of an SHC purity reporting sensor.""" _attr_translation_key = "purity" - _attr_icon = "mdi:molecule-co2" _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: @@ -256,7 +255,6 @@ class CommunicationQualitySensor(SHCEntity, SensorEntity): """Representation of an SHC communication quality reporting sensor.""" _attr_translation_key = "communication_quality" - _attr_icon = "mdi:wifi" def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize an SHC communication quality reporting sensor.""" @@ -339,7 +337,6 @@ def native_value(self): class ValveTappetSensor(SHCEntity, SensorEntity): """Representation of an SHC valve tappet reporting sensor.""" - _attr_icon = "mdi:gauge" _attr_translation_key = "valvetappet" _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 03d3ba2f6a9e70..8e542c860d4c00 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -199,7 +199,6 @@ def update(self) -> None: class SHCRoutingSwitch(SHCEntity, SwitchEntity): """Representation of a SHC routing switch.""" - _attr_icon = "mdi:wifi" _attr_translation_key = "routing" _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 59219a34eb78ed..72d2107271f807 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine, Iterable -from datetime import timedelta +from datetime import datetime, timedelta from functools import wraps import logging from types import MappingProxyType @@ -87,6 +87,8 @@ def __init__( self.media_content_type: MediaType | None = None self.media_uri: str | None = None self.media_duration: int | None = None + self.media_position: int | None = None + self.media_position_updated_at: datetime | None = None self.volume_level: float | None = None self.volume_target: str | None = None self.volume_muted = False @@ -185,6 +187,16 @@ async def async_update_playing(self) -> None: self.media_content_id = None self.media_content_type = None self.source = None + if start_datetime := playing_info.get("startDateTime"): + start_datetime = datetime.fromisoformat(start_datetime) + current_datetime = datetime.now().replace(tzinfo=start_datetime.tzinfo) + self.media_position = int( + (current_datetime - start_datetime).total_seconds() + ) + self.media_position_updated_at = datetime.now() + else: + self.media_position = None + self.media_position_updated_at = None if self.media_uri: self.media_content_id = self.media_uri if self.media_uri[:8] == "extInput": diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index cfa388fcce7e5d..111f08e441aedc 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -1,6 +1,7 @@ """Media player support for Bravia TV integration.""" from __future__ import annotations +from datetime import datetime from typing import Any from homeassistant.components.media_player import ( @@ -111,6 +112,16 @@ def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self.coordinator.media_duration + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + return self.coordinator.media_position + + @property + def media_position_updated_at(self) -> datetime | None: + """When was the position of the current playing media valid.""" + return self.coordinator.media_position_updated_at + async def async_turn_on(self) -> None: """Turn the device on.""" await self.coordinator.async_turn_on() diff --git a/homeassistant/components/brel_home/__init__.py b/homeassistant/components/brel_home/__init__.py new file mode 100644 index 00000000000000..2f57bb2e0b389a --- /dev/null +++ b/homeassistant/components/brel_home/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Brel Home.""" diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index aec3cd53c945e4..aaf11130b8dd16 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -3,8 +3,8 @@ import logging -from python_bring_api.bring import Bring -from python_bring_api.exceptions import ( +from bring_api.bring import Bring +from bring_api.exceptions import ( BringAuthException, BringParseException, BringRequestException, @@ -31,11 +31,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password = entry.data[CONF_PASSWORD] session = async_get_clientsession(hass) - bring = Bring(email, password, sessionAsync=session) + bring = Bring(session, email, password) try: - await bring.loginAsync() - await bring.loadListsAsync() + await bring.login() + await bring.load_lists() except BringRequestException as e: raise ConfigEntryNotReady( f"Timeout while connecting for email '{email}'" diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 122e71feea61b3..efd99fd938a0e7 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -4,8 +4,8 @@ import logging from typing import Any -from python_bring_api.bring import Bring -from python_bring_api.exceptions import BringAuthException, BringRequestException +from bring_api.bring import Bring +from bring_api.exceptions import BringAuthException, BringRequestException import voluptuous as vol from homeassistant import config_entries @@ -50,13 +50,11 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: session = async_get_clientsession(self.hass) - bring = Bring( - user_input[CONF_EMAIL], user_input[CONF_PASSWORD], sessionAsync=session - ) + bring = Bring(session, user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) try: - await bring.loginAsync() - await bring.loadListsAsync() + await bring.login() + await bring.load_lists() except BringRequestException: errors["base"] = "cannot_connect" except BringAuthException: diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index eb28f24e085725..550c589aa4e00f 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -4,9 +4,9 @@ from datetime import timedelta import logging -from python_bring_api.bring import Bring -from python_bring_api.exceptions import BringParseException, BringRequestException -from python_bring_api.types import BringItemsResponse, BringList +from bring_api.bring import Bring +from bring_api.exceptions import BringParseException, BringRequestException +from bring_api.types import BringList, BringPurchase from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -20,7 +20,8 @@ class BringData(BringList): """Coordinator data class.""" - items: list[BringItemsResponse] + purchase_items: list[BringPurchase] + recently_items: list[BringPurchase] class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): @@ -40,7 +41,7 @@ def __init__(self, hass: HomeAssistant, bring: Bring) -> None: async def _async_update_data(self) -> dict[str, BringData]: try: - lists_response = await self.bring.loadListsAsync() + lists_response = await self.bring.load_lists() except BringRequestException as e: raise UpdateFailed("Unable to connect and retrieve data from bring") from e except BringParseException as e: @@ -49,14 +50,15 @@ async def _async_update_data(self) -> dict[str, BringData]: list_dict = {} for lst in lists_response["lists"]: try: - items = await self.bring.getItemsAsync(lst["listUuid"]) + items = await self.bring.get_list(lst["listUuid"]) except BringRequestException as e: raise UpdateFailed( "Unable to connect and retrieve data from bring" ) from e except BringParseException as e: raise UpdateFailed("Unable to parse response from bring") from e - lst["items"] = items["purchase"] + lst["purchase_items"] = items["purchase"] + lst["recently_items"] = items["recently"] list_dict[lst["listUuid"]] = lst return list_dict diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json new file mode 100644 index 00000000000000..a757b20a4cce47 --- /dev/null +++ b/homeassistant/components/bring/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "todo": { + "shopping_list": { + "default": "mdi:cart" + } + } + } +} diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index e7d23bfc3df6f9..d8bfc6c7ebde1a 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["python-bring-api==3.0.0"] + "requirements": ["bring-api==0.5.5"] } diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 14279c894af733..5d3fc5bbf681c6 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -2,8 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING +import uuid -from python_bring_api.exceptions import BringRequestException +from bring_api.exceptions import BringRequestException +from bring_api.types import BringItem, BringItemOperation from homeassistant.components.todo import ( TodoItem, @@ -49,7 +51,7 @@ class BringTodoListEntity( ): """A To-do List representation of the Bring! Shopping List.""" - _attr_icon = "mdi:cart" + _attr_translation_key = "shopping_list" _attr_has_entity_name = True _attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -74,13 +76,24 @@ def __init__( def todo_items(self) -> list[TodoItem]: """Return the todo items.""" return [ - TodoItem( - uid=item["name"], - summary=item["name"], - description=item["specification"] or "", - status=TodoItemStatus.NEEDS_ACTION, - ) - for item in self.bring_list["items"] + *( + TodoItem( + uid=item["uuid"], + summary=item["itemId"], + description=item["specification"] or "", + status=TodoItemStatus.NEEDS_ACTION, + ) + for item in self.bring_list["purchase_items"] + ), + *( + TodoItem( + uid=item["uuid"], + summary=item["itemId"], + description=item["specification"] or "", + status=TodoItemStatus.COMPLETED, + ) + for item in self.bring_list["recently_items"] + ), ] @property @@ -91,8 +104,11 @@ def bring_list(self) -> BringData: async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" try: - await self.coordinator.bring.saveItemAsync( - self.bring_list["listUuid"], item.summary, item.description or "" + await self.coordinator.bring.save_item( + self.bring_list["listUuid"], + item.summary, + item.description or "", + str(uuid.uuid4()), ) except BringRequestException as e: raise HomeAssistantError("Unable to save todo item for bring") from e @@ -103,51 +119,76 @@ async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list. Bring has an internal 'recent' list which we want to use instead of a todo list - status, therefore completed todo list items will directly be deleted + status, therefore completed todo list items are matched to the recent list and + pending items to the purchase list. This results in following behaviour: - Completed items will move to the "completed" section in home assistant todo - list and get deleted in bring, which will remove them from the home - assistant todo list completely after a short delay - - Bring items do not have unique identifiers and are using the - name/summery/title. Therefore the name is not to be changed! Should a name - be changed anyway, a new item will be created instead and no update for - this item is performed and on the next cloud pull update, it will get - cleared + list and get moved to the recently list in bring + - Bring shows some odd behaviour when renaming items. This is because Bring + did not have unique identifiers for items in the past and this is still + a relic from it. Therefore the name is not to be changed! Should a name + be changed anyway, the item will be deleted and a new item will be created + instead and no update for this item is performed and on the next cloud pull + update, it will get cleared and replaced seamlessly. """ bring_list = self.bring_list + bring_purchase_item = next( + (i for i in bring_list["purchase_items"] if i["uuid"] == item.uid), + None, + ) + + bring_recently_item = next( + (i for i in bring_list["recently_items"] if i["uuid"] == item.uid), + None, + ) + + current_item = bring_purchase_item or bring_recently_item + if TYPE_CHECKING: assert item.uid + assert current_item - if item.status == TodoItemStatus.COMPLETED: - await self.coordinator.bring.removeItemAsync( - bring_list["listUuid"], - item.uid, - ) - - elif item.summary == item.uid: + if item.summary == current_item["itemId"]: try: - await self.coordinator.bring.updateItemAsync( + await self.coordinator.bring.batch_update_list( bring_list["listUuid"], - item.uid, - item.description or "", + BringItem( + itemId=item.summary, + spec=item.description, + uuid=item.uid, + ), + BringItemOperation.ADD + if item.status == TodoItemStatus.NEEDS_ACTION + else BringItemOperation.COMPLETE, ) except BringRequestException as e: raise HomeAssistantError("Unable to update todo item for bring") from e else: try: - await self.coordinator.bring.removeItemAsync( + await self.coordinator.bring.batch_update_list( bring_list["listUuid"], - item.uid, - ) - await self.coordinator.bring.saveItemAsync( - bring_list["listUuid"], - item.summary, - item.description or "", + [ + BringItem( + itemId=current_item["itemId"], + spec=item.description, + uuid=item.uid, + operation=BringItemOperation.REMOVE, + ), + BringItem( + itemId=item.summary, + spec=item.description, + uuid=str(uuid.uuid4()), + operation=BringItemOperation.ADD + if item.status == TodoItemStatus.NEEDS_ACTION + else BringItemOperation.COMPLETE, + ), + ], ) + except BringRequestException as e: raise HomeAssistantError("Unable to replace todo item for bring") from e @@ -155,12 +196,21 @@ async def async_update_todo_item(self, item: TodoItem) -> None: async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item from the To-do list.""" - for uid in uids: - try: - await self.coordinator.bring.removeItemAsync( - self.bring_list["listUuid"], uid - ) - except BringRequestException as e: - raise HomeAssistantError("Unable to delete todo item for bring") from e + + try: + await self.coordinator.bring.batch_update_list( + self.bring_list["listUuid"], + [ + BringItem( + itemId=uid, + spec="", + uuid=uid, + ) + for uid in uids + ], + BringItemOperation.REMOVE, + ) + except BringRequestException as e: + raise HomeAssistantError("Unable to delete todo item for bring") from e await self.coordinator.async_refresh() diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index dd37d270f9e633..be0eaf78f269d7 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -30,7 +30,7 @@ async def async_setup_entry( async_add_entities([BroadlinkThermostat(device)]) -class BroadlinkThermostat(ClimateEntity, BroadlinkEntity): +class BroadlinkThermostat(BroadlinkEntity, ClimateEntity): """Representation of a Broadlink Hysen climate entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/brother/diagnostics.py b/homeassistant/components/brother/diagnostics.py index 4e2b64b4f56f45..4733431f8e2581 100644 --- a/homeassistant/components/brother/diagnostics.py +++ b/homeassistant/components/brother/diagnostics.py @@ -22,6 +22,8 @@ async def async_get_config_entry_diagnostics( diagnostics_data = { "info": dict(config_entry.data), "data": asdict(coordinator.data), + "model": coordinator.brother.model, + "firmware": coordinator.brother.firmware, } return diagnostics_data diff --git a/homeassistant/components/brother/icons.json b/homeassistant/components/brother/icons.json new file mode 100644 index 00000000000000..0e609f4190a2fa --- /dev/null +++ b/homeassistant/components/brother/icons.json @@ -0,0 +1,105 @@ +{ + "entity": { + "sensor": { + "belt_unit_remaining_life": { + "default": "mdi:current-ac" + }, + "black_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "black_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "black_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "black_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "black_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "bw_pages": { + "default": "mdi:file-document-outline" + }, + "color_pages": { + "default": "mdi:file-document-outline" + }, + "cyan_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "cyan_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "cyan_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "cyan_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "cyan_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "drum_page_counter": { + "default": "mdi:chart-donut" + }, + "drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "duplex_unit_page_counter": { + "default": "mdi:file-document-outline" + }, + "fuser_remaining_life": { + "default": "mdi:water-outline" + }, + "laser_remaining_life": { + "default": "mdi:spotlight-beam" + }, + "magenta_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "magenta_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "magenta_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "magenta_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "magenta_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "status": { + "default": "mdi:printer" + }, + "page_counter": { + "default": "mdi:file-document-outline" + }, + "pf_kit_1_remaining_life": { + "default": "mdi:printer-3d" + }, + "pf_kit_mp_remaining_life": { + "default": "mdi:printer-3d" + }, + "yellow_drum_page_counter": { + "default": "mdi:chart-donut" + }, + "yellow_drum_remaining_life": { + "default": "mdi:chart-donut" + }, + "yellow_drum_remaining_pages": { + "default": "mdi:chart-donut" + }, + "yellow_ink_remaining": { + "default": "mdi:printer-3d-nozzle" + }, + "yellow_toner_remaining": { + "default": "mdi:printer-3d-nozzle" + } + } + } +} diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 06b8574dbb47c5..26317b39ab5fa2 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==3.0.0"], + "requirements": ["brother==4.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 27e4b7fd715255..198fe621246212 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -16,10 +16,10 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -52,14 +52,12 @@ class BrotherSensorEntityDescription( SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( BrotherSensorEntityDescription( key="status", - icon="mdi:printer", translation_key="status", entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.status, ), BrotherSensorEntityDescription( key="page_counter", - icon="mdi:file-document-outline", translation_key="page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -68,7 +66,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="bw_counter", - icon="mdi:file-document-outline", translation_key="bw_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -77,7 +74,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="color_counter", - icon="mdi:file-document-outline", translation_key="color_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -86,7 +82,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="duplex_unit_pages_counter", - icon="mdi:file-document-outline", translation_key="duplex_unit_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -95,7 +90,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="drum_remaining_life", - icon="mdi:chart-donut", translation_key="drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -104,7 +98,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="drum_remaining_pages", - icon="mdi:chart-donut", translation_key="drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -113,7 +106,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="drum_counter", - icon="mdi:chart-donut", translation_key="drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +114,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="black_drum_remaining_life", - icon="mdi:chart-donut", translation_key="black_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -131,7 +122,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="black_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="black_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -140,7 +130,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="black_drum_counter", - icon="mdi:chart-donut", translation_key="black_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -149,7 +138,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="cyan_drum_remaining_life", - icon="mdi:chart-donut", translation_key="cyan_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -158,7 +146,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="cyan_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="cyan_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -167,7 +154,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="cyan_drum_counter", - icon="mdi:chart-donut", translation_key="cyan_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -176,7 +162,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="magenta_drum_remaining_life", - icon="mdi:chart-donut", translation_key="magenta_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -185,7 +170,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="magenta_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="magenta_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -194,7 +178,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="magenta_drum_counter", - icon="mdi:chart-donut", translation_key="magenta_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -203,7 +186,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="yellow_drum_remaining_life", - icon="mdi:chart-donut", translation_key="yellow_drum_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -212,7 +194,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="yellow_drum_remaining_pages", - icon="mdi:chart-donut", translation_key="yellow_drum_remaining_pages", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -221,7 +202,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="yellow_drum_counter", - icon="mdi:chart-donut", translation_key="yellow_drum_page_counter", native_unit_of_measurement=UNIT_PAGES, state_class=SensorStateClass.MEASUREMENT, @@ -230,7 +210,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="belt_unit_remaining_life", - icon="mdi:current-ac", translation_key="belt_unit_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -239,7 +218,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="fuser_remaining_life", - icon="mdi:water-outline", translation_key="fuser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -248,7 +226,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="laser_remaining_life", - icon="mdi:spotlight-beam", translation_key="laser_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -257,7 +234,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="pf_kit_1_remaining_life", - icon="mdi:printer-3d", translation_key="pf_kit_1_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -266,7 +242,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="pf_kit_mp_remaining_life", - icon="mdi:printer-3d", translation_key="pf_kit_mp_remaining_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -275,7 +250,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="black_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="black_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -284,7 +258,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="cyan_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="cyan_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -293,7 +266,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="magenta_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="magenta_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -302,7 +274,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="yellow_toner_remaining", - icon="mdi:printer-3d-nozzle", translation_key="yellow_toner_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -311,7 +282,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="black_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="black_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -320,7 +290,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="cyan_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="cyan_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -329,7 +298,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="magenta_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="magenta_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -338,7 +306,6 @@ class BrotherSensorEntityDescription( ), BrotherSensorEntityDescription( key="yellow_ink_remaining", - icon="mdi:printer-3d-nozzle", translation_key="yellow_ink_remaining", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -365,11 +332,11 @@ async def async_setup_entry( # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new one. entity_registry = er.async_get(hass) - old_unique_id = f"{coordinator.data.serial.lower()}_b/w_counter" + old_unique_id = f"{coordinator.brother.serial.lower()}_b/w_counter" if entity_id := entity_registry.async_get_entity_id( PLATFORM, DOMAIN, old_unique_id ): - new_unique_id = f"{coordinator.data.serial.lower()}_bw_counter" + new_unique_id = f"{coordinator.brother.serial.lower()}_bw_counter" _LOGGER.debug( "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", entity_id, @@ -380,19 +347,9 @@ async def async_setup_entry( sensors = [] - device_info = DeviceInfo( - configuration_url=f"http://{entry.data[CONF_HOST]}/", - identifiers={(DOMAIN, coordinator.data.serial)}, - serial_number=coordinator.data.serial, - manufacturer="Brother", - model=coordinator.data.model, - name=coordinator.data.model, - sw_version=coordinator.data.firmware, - ) - for description in SENSOR_TYPES: if description.value(coordinator.data) is not None: - sensors.append(BrotherPrinterSensor(coordinator, description, device_info)) + sensors.append(BrotherPrinterSensor(coordinator, description)) async_add_entities(sensors, False) @@ -408,13 +365,21 @@ def __init__( self, coordinator: BrotherDataUpdateCoordinator, description: BrotherSensorEntityDescription, - device_info: DeviceInfo, ) -> None: """Initialize.""" super().__init__(coordinator) - self._attr_device_info = device_info + self._attr_device_info = DeviceInfo( + configuration_url=f"http://{coordinator.brother.host}/", + identifiers={(DOMAIN, coordinator.brother.serial)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)}, + serial_number=coordinator.brother.serial, + manufacturer="Brother", + model=coordinator.brother.model, + name=coordinator.brother.model, + sw_version=coordinator.brother.firmware, + ) self._attr_native_value = description.value(coordinator.data) - self._attr_unique_id = f"{coordinator.data.serial.lower()}_{description.key}" + self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}" self.entity_description = description @callback diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 511701cb538065..1be595bf1cc8f1 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -17,6 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -26,7 +27,7 @@ from homeassistant.util.enum import try_parse_enum from . import HomeAssistantBSBLANData -from .const import ATTR_TARGET_TEMPERATURE, DOMAIN, LOGGER +from .const import ATTR_TARGET_TEMPERATURE, DOMAIN from .entity import BSBLANEntity PARALLEL_UPDATES = 1 @@ -147,7 +148,12 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if self.hvac_mode == HVACMode.AUTO: await self.async_set_data(preset_mode=preset_mode) else: - LOGGER.error("Can't set preset mode when hvac mode is not auto") + raise ServiceValidationError( + "Can't set preset mode when hvac mode is not auto", + translation_domain=DOMAIN, + translation_key="set_preset_mode_error", + translation_placeholders={"preset_mode": preset_mode}, + ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -168,6 +174,10 @@ async def async_set_data(self, **kwargs: Any) -> None: data[ATTR_HVAC_MODE] = kwargs[ATTR_PRESET_MODE] try: await self.client.thermostat(**data) - except BSBLANError: - LOGGER.error("An error occurred while updating the BSBLAN device") + except BSBLANError as err: + raise HomeAssistantError( + "An error occurred while updating the BSBLAN device", + translation_domain=DOMAIN, + translation_key="set_data_error", + ) from err await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 689d1f893d3c05..7a67d353803957 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -24,5 +24,13 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "exceptions": { + "set_preset_mode_error": { + "message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto" + }, + "set_data_error": { + "message": "An error occurred while sending the data to the BSBLAN device" + } } } diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 41440cb435fcd4..62dc8cfa99f3a8 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -1,4 +1,5 @@ """Config flow for BTHome Bluetooth integration.""" + from __future__ import annotations from collections.abc import Mapping @@ -11,7 +12,7 @@ from homeassistant.components import onboarding from homeassistant.components.bluetooth import ( - BluetoothServiceInfo, + BluetoothServiceInfoBleak, async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow @@ -26,11 +27,11 @@ class Discovery: """A discovered bluetooth device.""" title: str - discovery_info: BluetoothServiceInfo + discovery_info: BluetoothServiceInfoBleak device: DeviceData -def _title(discovery_info: BluetoothServiceInfo, device: DeviceData) -> str: +def _title(discovery_info: BluetoothServiceInfoBleak, device: DeviceData) -> str: return device.title or device.get_device_name() or discovery_info.name @@ -41,12 +42,12 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._discovery_info: BluetoothServiceInfo | None = None + self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_device: DeviceData | None = None self._discovered_devices: dict[str, Discovery] = {} async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfo + self, discovery_info: BluetoothServiceInfoBleak ) -> FlowResult: """Handle the bluetooth discovery step.""" await self.async_set_unique_id(discovery_info.address) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 2a7cf84f16bb88..a3e974bf71e2e5 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==3.5.0"] + "requirements": ["bthome-ble==3.6.0"] } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 1963041bccad37..ba62cbfbb19595 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -128,7 +128,7 @@ async def __retrieve_radar_image(self) -> bool: _LOGGER.debug("HTTP 200 - Last-Modified: %s", last_modified) return True - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Failed to fetch image, %s", type(err)) return False diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 63e0004dc43cc7..426f982bafcc32 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,5 +1,4 @@ """Shared utilities for different supported platforms.""" -import asyncio from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus @@ -104,7 +103,7 @@ async def get_data(self, url): result[MESSAGE] = "Got http statuscode: %d" % (resp.status) return result - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: result[MESSAGE] = str(err) return result finally: diff --git a/homeassistant/components/caldav/api.py b/homeassistant/components/caldav/api.py index fa89d6acc384df..3b524e29370e40 100644 --- a/homeassistant/components/caldav/api.py +++ b/homeassistant/components/caldav/api.py @@ -1,6 +1,5 @@ """Library for working with CalDAV api.""" -import asyncio import caldav @@ -13,20 +12,13 @@ async def async_get_calendars( """Get all calendars that support the specified component.""" def _get_calendars() -> list[caldav.Calendar]: - return client.principal().calendars() - - calendars = await hass.async_add_executor_job(_get_calendars) - components_results = await asyncio.gather( - *[ - hass.async_add_executor_job(calendar.get_supported_components) - for calendar in calendars + return [ + calendar + for calendar in client.principal().calendars() + if component in calendar.get_supported_components() ] - ) - return [ - calendar - for calendar, supported_components in zip(calendars, components_results) - if component in supported_components - ] + + return await hass.async_add_executor_job(_get_calendars) def get_attr_value(obj: caldav.CalendarObjectResource, attribute: str) -> str | None: diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 073c41fc0dfb2b..e4fe5d22efdc91 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -91,11 +91,24 @@ def __str__(self) -> str: QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]] -def event_fetcher(hass: HomeAssistant, entity: CalendarEntity) -> EventFetcher: +def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: + """Get the calendar entity for the provided entity_id.""" + component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] + if not (entity := component.get_entity(entity_id)) or not isinstance( + entity, CalendarEntity + ): + raise HomeAssistantError( + f"Entity does not exist {entity_id} or is not a calendar entity" + ) + return entity + + +def event_fetcher(hass: HomeAssistant, entity_id: str) -> 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.""" + entity = get_entity(hass, entity_id) # 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) @@ -237,7 +250,10 @@ async def _handle_refresh(self, now_utc: datetime.datetime) -> None: self._dispatch_events(now) self._clear_event_listener() self._timespan = self._timespan.next_upcoming(now, UPDATE_INTERVAL) - self._events.extend(await self._fetcher(self._timespan)) + try: + self._events.extend(await self._fetcher(self._timespan)) + except HomeAssistantError as ex: + _LOGGER.error("Calendar trigger failed to fetch events: %s", ex) self._listen_next_calendar_event() @@ -252,13 +268,8 @@ async def async_attach_trigger( event_type = config[CONF_EVENT] offset = config[CONF_OFFSET] - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(entity_id)) or not isinstance( - entity, CalendarEntity - ): - raise HomeAssistantError( - f"Entity does not exist {entity_id} or is not a calendar entity" - ) + # Validate the entity id is valid + get_entity(hass, entity_id) trigger_data = { **trigger_info["trigger_data"], @@ -270,7 +281,7 @@ async def async_attach_trigger( hass, HassJob(action), trigger_data, - queued_event_fetcher(event_fetcher(hass, entity), event_type, offset), + queued_event_fetcher(event_fetcher(hass, entity_id), 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 5a78728697b927..ff4687dd493e4a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -181,7 +181,7 @@ async def _async_get_image( that we can scale, however the majority of cases are handled. """ - with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with suppress(asyncio.CancelledError, TimeoutError): async with asyncio.timeout(timeout): image_bytes = ( await _async_get_stream_image( @@ -300,8 +300,12 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: if img_bytes != last_image: await write_to_mjpeg_stream(img_bytes) - # Chrome seems to always ignore first picture, - # print it twice. + # Chrome always shows the n-1 frame: + # https://issues.chromium.org/issues/41199053 + # https://issues.chromium.org/issues/40791855 + # We send the first frame twice to ensure it shows + # Subsequent frames are not a concern at reasonable frame rates + # (even 1/10 FPS is about the latency of HLS) if last_image is None: await write_to_mjpeg_stream(img_bytes) last_image = img_bytes @@ -387,6 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) prefs = CameraPreferences(hass) + await prefs.async_load() hass.data[DATA_CAMERA_PREFS] = prefs hass.http.register_view(CameraImageView(component)) @@ -891,7 +896,7 @@ async def ws_camera_stream( except HomeAssistantError as ex: _LOGGER.error("Error requesting stream: %s", ex) connection.send_error(msg["id"], "start_stream_failed", str(ex)) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout getting stream source") connection.send_error( msg["id"], "start_stream_failed", "Timeout getting stream source" @@ -936,7 +941,7 @@ async def ws_camera_web_rtc_offer( except (HomeAssistantError, ValueError) as ex: _LOGGER.error("Error handling WebRTC offer: %s", ex) connection.send_error(msg["id"], "web_rtc_offer_failed", str(ex)) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout handling WebRTC offer") connection.send_error( msg["id"], "web_rtc_offer_failed", "Timeout handling WebRTC offer" diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index e681ddbbd7e309..3c9a386f958f6e 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -1,6 +1,8 @@ """Expose cameras as media sources.""" from __future__ import annotations +import asyncio + from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.media_source.models import ( @@ -23,6 +25,19 @@ async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource: return CameraMediaSource(hass) +def _media_source_for_camera(camera: Camera, content_type: str) -> BrowseMediaSource: + return BrowseMediaSource( + domain=DOMAIN, + identifier=camera.entity_id, + media_class=MediaClass.VIDEO, + media_content_type=content_type, + title=camera.name, + thumbnail=f"/api/camera_proxy/{camera.entity_id}", + can_play=True, + can_expand=False, + ) + + class CameraMediaSource(MediaSource): """Provide camera feeds as media sources.""" @@ -71,36 +86,28 @@ async def async_browse_media( can_stream_hls = "stream" in self.hass.config.components - # Root. List cameras. - component: EntityComponent[Camera] = self.hass.data[DOMAIN] - children = [] - not_shown = 0 - for camera in component.entities: + async def _filter_browsable_camera(camera: Camera) -> BrowseMediaSource | None: stream_type = camera.frontend_stream_type - if stream_type is None: - content_type = camera.content_type - - elif can_stream_hls and stream_type == StreamType.HLS: - content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] - - else: - not_shown += 1 - continue - - children.append( - BrowseMediaSource( - domain=DOMAIN, - identifier=camera.entity_id, - media_class=MediaClass.VIDEO, - media_content_type=content_type, - title=camera.name, - thumbnail=f"/api/camera_proxy/{camera.entity_id}", - can_play=True, - can_expand=False, - ) - ) + return _media_source_for_camera(camera, camera.content_type) + if not can_stream_hls: + return None + + content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] + if stream_type != StreamType.HLS and not (await camera.stream_source()): + return None + + return _media_source_for_camera(camera, content_type) + component: EntityComponent[Camera] = self.hass.data[DOMAIN] + results = await asyncio.gather( + *(_filter_browsable_camera(camera) for camera in component.entities), + return_exceptions=True, + ) + children = [ + result for result in results if isinstance(result, BrowseMediaSource) + ] + not_shown = len(results) - len(children) return BrowseMediaSource( domain=DOMAIN, identifier=None, diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index 160f896c86ca8c..7f3f142378a3a9 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -29,6 +29,8 @@ class DynamicStreamSettings: class CameraPreferences: """Handle camera preferences.""" + _preload_prefs: dict[str, dict[str, bool | Orientation]] + def __init__(self, hass: HomeAssistant) -> None: """Initialize camera prefs.""" self._hass = hass @@ -41,6 +43,10 @@ def __init__(self, hass: HomeAssistant) -> None: str, DynamicStreamSettings ] = {} + async def async_load(self) -> None: + """Initialize the camera preferences.""" + self._preload_prefs = await self._store.async_load() or {} + async def async_update( self, entity_id: str, @@ -63,9 +69,8 @@ async def async_update( if preload_stream is not UNDEFINED: if dynamic_stream_settings: dynamic_stream_settings.preload_stream = preload_stream - preload_prefs = await self._store.async_load() or {} - preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream} - await self._store.async_save(preload_prefs) + self._preload_prefs[entity_id] = {PREF_PRELOAD_STREAM: preload_stream} + await self._store.async_save(self._preload_prefs) if orientation is not UNDEFINED: if (registry := er.async_get(self._hass)).async_get(entity_id): @@ -91,10 +96,10 @@ async def get_dynamic_stream_settings( # Get orientation setting from entity registry reg_entry = er.async_get(self._hass).async_get(entity_id) 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( - bool, preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False) + bool, + self._preload_prefs.get(entity_id, {}).get(PREF_PRELOAD_STREAM, False), ), orientation=er_prefs.get(PREF_ORIENTATION, Orientation.NO_TRANSFORM), ) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index aa0bdfa81187b5..8c574e0792bacc 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.media_player import BrowseMedia, MediaType 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 import config_validation as cv, device_registry as dr from homeassistant.helpers.integration_platform import ( @@ -66,7 +66,8 @@ async def async_play_media( """ -async def _register_cast_platform( +@callback +def _register_cast_platform( hass: HomeAssistant, integration_domain: str, platform: CastProtocol ): """Register a cast platform.""" diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 730757de8b4715..f05c2c4c143373 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,9 +1,7 @@ """Consts for Cast integration.""" from __future__ import annotations -from typing import TYPE_CHECKING - -from pychromecast.controllers.homeassistant import HomeAssistantController +from typing import TYPE_CHECKING, TypedDict from homeassistant.helpers.dispatcher import SignalType @@ -33,8 +31,17 @@ # Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. SIGNAL_HASS_CAST_SHOW_VIEW: SignalType[ - HomeAssistantController, str, str, str | None + HomeAssistantControllerData, str, str, str | None ] = SignalType("cast_show_view") CONF_IGNORE_CEC = "ignore_cec" CONF_KNOWN_HOSTS = "known_hosts" + + +class HomeAssistantControllerData(TypedDict): + """Data for creating a HomeAssistantController.""" + + hass_url: str + hass_uuid: str + client_id: str | None + refresh_token: str diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 8b8862ab318bf4..bfe0bc70d7908e 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,7 +1,6 @@ """Helpers to deal with Cast devices.""" from __future__ import annotations -import asyncio import configparser from dataclasses import dataclass import logging @@ -183,10 +182,10 @@ def new_media_status(self, status): if self._valid: self._cast_device.new_media_status(status) - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle reception of a new MediaStatus.""" if self._valid: - self._cast_device.load_media_failed(item, error_code) + self._cast_device.load_media_failed(queue_item_id, error_code) def new_connection_status(self, status): """Handle reception of a new ConnectionStatus.""" @@ -257,7 +256,7 @@ async def _fetch_playlist(hass, url, supported_content_types): playlist_data = (await resp.content.read(64 * 1024)).decode(charset) except ValueError as err: raise PlaylistError(f"Could not decode playlist {url}") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise PlaylistError(f"Timeout while fetching playlist {url}") from err except aiohttp.client_exceptions.ClientError as err: raise PlaylistError(f"Error while fetching playlist {url}") from err @@ -295,10 +294,7 @@ async def parse_m3u(hass, url): continue length = info[0].split(" ", 1) title = info[1].strip() - elif line.startswith("#EXT-X-VERSION:"): - # HLS stream, supported by cast devices - raise PlaylistSupported("HLS") - elif line.startswith("#EXT-X-STREAM-INF:"): + elif line.startswith(("#EXT-X-VERSION:", "#EXT-X-STREAM-INF:")): # HLS stream, supported by cast devices raise PlaylistSupported("HLS") elif line.startswith("#"): diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index 5eec2a2890834f..f7518b9519ae49 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -1,7 +1,6 @@ """Home Assistant Cast integration for Cast.""" from __future__ import annotations -from pychromecast.controllers.homeassistant import HomeAssistantController import voluptuous as vol from homeassistant import auth, config_entries, core @@ -11,7 +10,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.service import async_register_admin_service -from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData SERVICE_SHOW_VIEW = "show_lovelace_view" ATTR_VIEW_PATH = "view_path" @@ -55,7 +54,7 @@ async def handle_show_view(call: core.ServiceCall) -> None: hass_uuid = await instance_id.async_get(hass) - controller = HomeAssistantController( + controller_data = HomeAssistantControllerData( # If you are developing Home Assistant Cast, uncomment and set to # your dev app id. # app_id="5FE44367", @@ -68,7 +67,7 @@ async def handle_show_view(call: core.ServiceCall) -> None: dispatcher.async_dispatcher_send( hass, SIGNAL_HASS_CAST_SHOW_VIEW, - controller, + controller_data, call.data[ATTR_ENTITY_ID], call.data[ATTR_VIEW_PATH], call.data.get(ATTR_URL_PATH), diff --git a/homeassistant/components/cast/icons.json b/homeassistant/components/cast/icons.json new file mode 100644 index 00000000000000..e19ea0b07b23b8 --- /dev/null +++ b/homeassistant/components/cast/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "show_lovelace_view": "mdi:view-dashboard" + } +} diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index ae049fefef62e8..d02bcd3558a455 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.1.0"], + "requirements": ["PyChromecast==14.0.0"], "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b472b18bed0799..b2893a5431018f 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -61,6 +61,7 @@ SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_REMOVED, SIGNAL_HASS_CAST_SHOW_VIEW, + HomeAssistantControllerData, ) from .discovery import setup_internal_discovery from .helpers import ( @@ -389,15 +390,15 @@ def new_media_status(self, media_status): self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() - def load_media_failed(self, item, error_code): + def load_media_failed(self, queue_item_id, error_code): """Handle load media failed.""" _LOGGER.debug( - "[%s %s] Load media failed with code %s(%s) for item %s", + "[%s %s] Load media failed with code %s(%s) for queue_item_id %s", self.entity_id, self._cast_info.friendly_name, error_code, MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"), - item, + queue_item_id, ) def new_connection_status(self, connection_status): @@ -951,7 +952,7 @@ def media_position_updated_at(self): def _handle_signal_show_view( self, - controller: HomeAssistantController, + controller_data: HomeAssistantControllerData, entity_id: str, view_path: str, url_path: str | None, @@ -961,6 +962,23 @@ def _handle_signal_show_view( return if self._hass_cast_controller is None: + + def unregister() -> None: + """Handle request to unregister the handler.""" + if not self._hass_cast_controller or not self._chromecast: + return + _LOGGER.debug( + "[%s %s] Unregistering HomeAssistantController", + self.entity_id, + self._cast_info.friendly_name, + ) + + self._chromecast.unregister_handler(self._hass_cast_controller) + self._hass_cast_controller = None + + controller = HomeAssistantController( + **controller_data, unregister=unregister + ) self._hass_cast_controller = controller self._chromecast.register_handler(controller) diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index cde9364214e5fd..6d10d75070504b 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -55,7 +55,7 @@ async def get_cert_expiry_timestamp( cert = await async_get_cert(hass, hostname, port) except socket.gaierror as err: raise ResolveFailed(f"Cannot resolve hostname: {hostname}") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConnectionTimeout( f"Connection timeout with server: {hostname}:{port}" ) from err diff --git a/homeassistant/components/cert_expiry/icons.json b/homeassistant/components/cert_expiry/icons.json new file mode 100644 index 00000000000000..9d86e701997df8 --- /dev/null +++ b/homeassistant/components/cert_expiry/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "certificate_expiry": { + "default": "mdi:certificate" + } + } + } +} diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 68e18fddc140ec..3e171006bdcda3 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -77,7 +77,6 @@ async def async_setup_entry( class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): """Defines a base Cert Expiry entity.""" - _attr_icon = "mdi:certificate" _attr_has_entity_name = True @property diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index fcd780dba7d7d6..fc49331c1b73f4 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -144,7 +144,7 @@ async def async_citybikes_request(hass, uri, schema): json_response = await req.json() return schema(json_response) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Could not connect to CityBikes API endpoint") except ValueError: _LOGGER.error("Received non-JSON data from CityBikes API endpoint") diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 43d98ad6bbddac..7e3cb027506f70 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -14,6 +14,7 @@ ATTR_TEMPERATURE, PRECISION_TENTHS, PRECISION_WHOLE, + SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -166,6 +167,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_turn_off", [ClimateEntityFeature.TURN_OFF], ) + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [ClimateEntityFeature.TURN_OFF, ClimateEntityFeature.TURN_ON], + ) component.async_register_entity_service( SERVICE_SET_HVAC_MODE, {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, @@ -756,7 +763,9 @@ async def async_turn_on(self) -> None: if mode not in self.hvac_modes: continue await self.async_set_hvac_mode(mode) - break + return + + raise NotImplementedError def turn_off(self) -> None: """Turn the entity off.""" @@ -772,6 +781,26 @@ async def async_turn_off(self) -> None: # Fake turn off if HVACMode.OFF in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.OFF) + return + + raise NotImplementedError + + def toggle(self) -> None: + """Toggle the entity.""" + raise NotImplementedError + + async def async_toggle(self) -> None: + """Toggle the entity.""" + # Forward to self.toggle if it's been overridden. + if type(self).toggle is not ClimateEntity.toggle: + await self.hass.async_add_executor_job(self.toggle) + return + + # We assume that since turn_off is supported, HVACMode.OFF is as well. + if self.hvac_mode == HVACMode.OFF: + await self.async_turn_on() + else: + await self.async_turn_off() @cached_property def supported_features(self) -> ClimateEntityFeature: diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 62952c5aae3dc6..12a8e6f001faee 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -148,3 +148,11 @@ turn_off: domain: climate supported_features: - climate.ClimateEntityFeature.TURN_OFF + +toggle: + target: + entity: + domain: climate + supported_features: + - climate.ClimateEntityFeature.TURN_OFF + - climate.ClimateEntityFeature.TURN_ON diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index ef87f287430897..eb9285b0c4fb0d 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -219,6 +219,10 @@ "turn_off": { "name": "[%key:common::action::turn_off%]", "description": "Turns climate device off." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles climate device, from on to off, or off to on." } }, "selector": { diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 1423330cb44f6d..f1e5d1a6903903 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,7 +1,6 @@ """Account linking via the cloud.""" from __future__ import annotations -import asyncio from datetime import datetime import logging from typing import Any @@ -69,7 +68,7 @@ async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: try: services = await account_link.async_fetch_available_services(hass.data[DOMAIN]) - except (aiohttp.ClientError, asyncio.TimeoutError): + except (aiohttp.ClientError, TimeoutError): return [] hass.data[DATA_SERVICES] = services @@ -114,7 +113,7 @@ async def await_tokens() -> None: try: tokens = await helper.async_get_tokens() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.info("Timeout fetching tokens for flow %s", flow_id) except account_link.AccountLinkException as err: _LOGGER.info( diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index e85c6dd277a197..415f2415095148 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -246,21 +246,27 @@ async def on_hass_started(hass: HomeAssistant) -> None: await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) - async_listen_entity_updates( - self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + self._on_deinitialize.append( + async_listen_entity_updates( + self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated + ) ) async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) - start.async_at_start(self.hass, on_hass_start) - start.async_at_started(self.hass, on_hass_started) + self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) + self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) - self._prefs.async_listen_updates(self._async_prefs_updated) - self.hass.bus.async_listen( - er.EVENT_ENTITY_REGISTRY_UPDATED, - self._handle_entity_registry_updated, + self._on_deinitialize.append( + self._prefs.async_listen_updates(self._async_prefs_updated) + ) + self._on_deinitialize.append( + self.hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, + self._handle_entity_registry_updated, + ) ) def _should_expose_legacy(self, entity_id: str) -> bool: @@ -505,7 +511,7 @@ async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool return True - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout trying to sync entities to Alexa") return False diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 8cf79d20c5d82d..e569602f944f24 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -9,7 +9,7 @@ from typing import Any, Literal import aiohttp -from hass_nabucasa.client import CloudClient as Interface +from hass_nabucasa.client import CloudClient as Interface, RemoteActivationNotAllowed from homeassistant.components import google_assistant, persistent_notification, webhook from homeassistant.components.alexa import ( @@ -213,6 +213,10 @@ async def logout_cleanups(self) -> None: """Cleanup some stuff after logout.""" await self.prefs.async_set_username(None) + if self._alexa_config: + self._alexa_config.async_deinitialize() + self._alexa_config = None + if self._google_config: self._google_config.async_deinitialize() self._google_config = None @@ -230,6 +234,8 @@ def dispatcher_message(self, identifier: str, data: Any = None) -> None: async def async_cloud_connect_update(self, connect: bool) -> None: """Process cloud remote message to client.""" + if not self._prefs.remote_allow_remote_enable: + raise RemoteActivationNotAllowed await self._prefs.async_update(remote_enabled=connect) async def async_cloud_connection_info( @@ -238,6 +244,7 @@ async def async_cloud_connection_info( """Process cloud connection info message to client.""" return { "remote": { + "can_enable": self._prefs.remote_allow_remote_enable, "connected": self.cloud.remote.is_connected, "enabled": self._prefs.remote_enabled, "instance_domain": self.cloud.remote.instance_domain, @@ -263,13 +270,23 @@ async def async_google_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud google message to client.""" gconf = await self.get_google_config() + msgid: Any = "" + if isinstance(payload, dict): + msgid = payload.get("requestId") + _LOGGER.debug("Received cloud message %s", msgid) + if not self._prefs.google_enabled: return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call] payload, gconf.agent_user_id ) return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call] - self._hass, gconf, gconf.cloud_user, payload, google_assistant.SOURCE_CLOUD + self._hass, + gconf, + gconf.agent_user_id, + gconf.cloud_user, + payload, + google_assistant.SOURCE_CLOUD, ) async def async_webhook_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 97d2345f16bb21..f704fb61f69674 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -30,6 +30,8 @@ PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" +PREF_GOOGLE_CONNECTED = "google_connected" +PREF_REMOTE_ALLOW_REMOTE_ENABLE = "remote_allow_remote_enable" DEFAULT_TTS_DEFAULT_VOICE = ("en-US", "female") DEFAULT_DISABLE_2FA = False DEFAULT_ALEXA_REPORT_STATE = True diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 42f25f43ae12e7..bda2412b476f5f 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -23,7 +23,6 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.core import ( - CALLBACK_TYPE, CoreState, Event, HomeAssistant, @@ -145,7 +144,6 @@ def __init__( self._prefs = prefs self._cloud = cloud self._sync_entities_lock = asyncio.Lock() - self._on_deinitialize: list[CALLBACK_TYPE] = [] @property def enabled(self) -> bool: @@ -175,8 +173,12 @@ def get_local_webhook_id(self, agent_user_id: Any) -> str: """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" return self._prefs.google_local_webhook_id - def get_local_agent_user_id(self, webhook_id: Any) -> str: - """Return the user ID to be used for actions received via the local SDK.""" + def get_local_user_id(self, webhook_id: Any) -> str: + """Map webhook ID to a Home Assistant user ID. + + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. + """ return self._user @property @@ -256,17 +258,6 @@ async def on_hass_start(hass: HomeAssistant) -> None: self._on_deinitialize.append(start.async_at_start(self.hass, on_hass_start)) self._on_deinitialize.append(start.async_at_started(self.hass, on_hass_started)) - # Remove any stored user agent id that is not ours - remove_agent_user_ids = [] - for agent_user_id in self._store.agent_user_ids: - if agent_user_id != self.agent_user_id: - remove_agent_user_ids.append(agent_user_id) - - if remove_agent_user_ids: - _LOGGER.debug("remove non cloud agent_user_ids: %s", remove_agent_user_ids) - for agent_user_id in remove_agent_user_ids: - await self.async_disconnect_agent_user(agent_user_id) - self._on_deinitialize.append( self._prefs.async_listen_updates(self._async_prefs_updated) ) @@ -283,13 +274,6 @@ async def on_hass_start(hass: HomeAssistant) -> None: ) ) - @callback - def async_deinitialize(self) -> None: - """Remove listeners.""" - _LOGGER.debug("async_deinitialize") - while self._on_deinitialize: - self._on_deinitialize.pop()() - def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) @@ -344,12 +328,22 @@ def agent_user_id(self) -> str: @property def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" - return len(self._store.agent_user_ids) > 0 + return len(self.async_get_agent_users()) > 0 - def get_agent_user_id(self, context: Any) -> str: + def get_agent_user_id_from_context(self, context: Any) -> str: """Get agent user ID making request.""" return self.agent_user_id + def get_agent_user_id_from_webhook(self, webhook_id: str) -> str | None: + """Map webhook ID to a Google agent user ID. + + Return None if no agent user id is found for the webhook_id. + """ + if webhook_id != self._prefs.google_local_webhook_id: + return None + + return self.agent_user_id + def _2fa_disabled_legacy(self, entity_id: str) -> bool | None: """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs @@ -385,6 +379,30 @@ async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus | resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status + async def async_connect_agent_user(self, agent_user_id: str) -> None: + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + await self._prefs.async_update(google_connected=True) + + async def async_disconnect_agent_user(self, agent_user_id: str) -> None: + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + await self._prefs.async_update(google_connected=False) + + @callback + def async_get_agent_users(self) -> tuple: + """Return known agent users.""" + if not self._prefs.google_connected or not self._cloud.username: + return () + return (self._cloud.username,) + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" _LOGGER.debug("_async_prefs_updated") diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 849a1c99db996f..4fd9d5c0301877 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -44,6 +44,7 @@ PREF_ENABLE_GOOGLE, PREF_GOOGLE_REPORT_STATE, PREF_GOOGLE_SECURE_DEVICES_PIN, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_TTS_DEFAULT_VOICE, REQUEST_TIMEOUT, ) @@ -55,7 +56,7 @@ _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { - asyncio.TimeoutError: ( + TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", ), @@ -235,7 +236,7 @@ class CloudLogoutView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.logout() @@ -262,7 +263,7 @@ class CloudRegisterView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle registration request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] client_metadata = None @@ -299,7 +300,7 @@ class CloudResendConfirmView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle resending confirm email code request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_resend_email_confirm(data["email"]) @@ -319,7 +320,7 @@ class CloudForgotPasswordView(HomeAssistantView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app["hass"] - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(REQUEST_TIMEOUT): await cloud.auth.async_forgot_password(data["email"]) @@ -338,7 +339,7 @@ async def websocket_cloud_status( Async friendly. """ - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] connection.send_message( websocket_api.result_message(msg["id"], await _account_data(hass, cloud)) ) @@ -362,7 +363,7 @@ def with_cloud_auth( msg: dict[str, Any], ) -> None: """Require to be logged into the cloud.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] if not cloud.is_logged_in: connection.send_message( websocket_api.error_message( @@ -385,7 +386,7 @@ async def websocket_subscription( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] if (data := await async_subscription_info(cloud)) is None: connection.send_error( msg["id"], "request_failed", "Failed to request subscription" @@ -408,6 +409,7 @@ async def websocket_subscription( vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), vol.In(MAP_VOICE) ), + vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, } ) @websocket_api.async_response @@ -417,7 +419,7 @@ async def websocket_update_prefs( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] changes = dict(msg) changes.pop("id") @@ -429,7 +431,7 @@ async def websocket_update_prefs( try: async with asyncio.timeout(10): await alexa_config.async_get_access_token() - except asyncio.TimeoutError: + except TimeoutError: connection.send_error( msg["id"], "alexa_timeout", "Timeout validating Alexa access token." ) @@ -468,7 +470,7 @@ async def websocket_hook_create( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] hook = await cloud.cloudhooks.async_create(msg["webhook_id"], False) connection.send_message(websocket_api.result_message(msg["id"], hook)) @@ -488,7 +490,7 @@ async def websocket_hook_delete( msg: dict[str, Any], ) -> None: """Handle request for account info.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.cloudhooks.async_delete(msg["webhook_id"]) connection.send_message(websocket_api.result_message(msg["id"])) @@ -557,7 +559,7 @@ async def websocket_remote_connect( msg: dict[str, Any], ) -> None: """Handle request for connect remote.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=True) connection.send_result(msg["id"], await _account_data(hass, cloud)) @@ -573,7 +575,7 @@ async def websocket_remote_disconnect( msg: dict[str, Any], ) -> None: """Handle request for disconnect remote.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.client.prefs.async_update(remote_enabled=False) connection.send_result(msg["id"], await _account_data(hass, cloud)) @@ -594,7 +596,7 @@ async def google_assistant_get( msg: dict[str, Any], ) -> None: """Get data for a single google assistant entity.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() entity_id: str = msg["entity_id"] state = hass.states.get(entity_id) @@ -642,7 +644,7 @@ async def google_assistant_list( msg: dict[str, Any], ) -> None: """List all google assistant entities.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() entities = google_helpers.async_get_entities(hass, gconf) @@ -736,7 +738,7 @@ async def alexa_list( msg: dict[str, Any], ) -> None: """List all alexa entities.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() entities = alexa_entities.async_get_entities(hass, alexa_config) @@ -764,7 +766,7 @@ async def alexa_sync( msg: dict[str, Any], ) -> None: """Sync with Alexa.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] alexa_config = await cloud.client.get_alexa_config() async with asyncio.timeout(10): @@ -794,7 +796,7 @@ async def thingtalk_convert( msg: dict[str, Any], ) -> None: """Convert a query.""" - cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async with asyncio.timeout(10): try: diff --git a/homeassistant/components/cloud/icons.json b/homeassistant/components/cloud/icons.json new file mode 100644 index 00000000000000..06ee7eb2f197f4 --- /dev/null +++ b/homeassistant/components/cloud/icons.json @@ -0,0 +1,6 @@ +{ + "services": { + "remote_connect": "mdi:cloud", + "remote_disconnect": "mdi:cloud-off" + } +} diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index d314aac2092a05..ef2d32fcb0c129 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -5,8 +5,9 @@ "codeowners": ["@home-assistant/cloud"], "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", + "import_executor": true, "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.76.0"] + "requirements": ["hass-nabucasa==0.78.0"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index af5f9213e4dd78..010a9697f266ef 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -8,6 +8,9 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook +from homeassistant.components.google_assistant.http import ( + async_get_users as async_get_google_assistant_users, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import UNDEFINED, UndefinedType @@ -28,6 +31,7 @@ PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, + PREF_GOOGLE_CONNECTED, PREF_GOOGLE_DEFAULT_EXPOSE, PREF_GOOGLE_ENTITY_CONFIGS, PREF_GOOGLE_LOCAL_WEBHOOK_ID, @@ -35,6 +39,7 @@ PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_GOOGLE_SETTINGS_VERSION, PREF_INSTANCE_ID, + PREF_REMOTE_ALLOW_REMOTE_ENABLE, PREF_REMOTE_DOMAIN, PREF_TTS_DEFAULT_VOICE, PREF_USERNAME, @@ -42,7 +47,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 ALEXA_SETTINGS_VERSION = 3 GOOGLE_SETTINGS_VERSION = 3 @@ -55,10 +60,27 @@ async def _async_migrate_func( self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] ) -> dict[str, Any]: """Migrate to the new version.""" + + async def google_connected() -> bool: + """Return True if our user is preset in the google_assistant store.""" + # If we don't have a user, we can't be connected to Google + if not (cur_username := old_data.get(PREF_USERNAME)): + return False + + # If our user is in the Google store, we're connected + return cur_username in await async_get_google_assistant_users(self.hass) + if old_major_version == 1: if old_minor_version < 2: old_data.setdefault(PREF_ALEXA_SETTINGS_VERSION, 1) old_data.setdefault(PREF_GOOGLE_SETTINGS_VERSION, 1) + if old_minor_version < 3: + # Import settings from the google_assistant store which was previously + # shared between the cloud integration and manually configured Google + # assistant. + # In HA Core 2024.9, remove the import and also remove the Google + # assistant store if it's not been migrated by manual Google assistant + old_data.setdefault(PREF_GOOGLE_CONNECTED, await google_connected()) return old_data @@ -131,6 +153,8 @@ async def async_update( remote_domain: str | None | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED, google_settings_version: int | UndefinedType = UNDEFINED, + google_connected: bool | UndefinedType = UNDEFINED, + remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -148,6 +172,8 @@ async def async_update( (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), (PREF_TTS_DEFAULT_VOICE, tts_default_voice), (PREF_REMOTE_DOMAIN, remote_domain), + (PREF_GOOGLE_CONNECTED, google_connected), + (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), ): if value is not UNDEFINED: prefs[key] = value @@ -189,9 +215,16 @@ def as_dict(self) -> dict[str, Any]: PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, PREF_GOOGLE_REPORT_STATE: self.google_report_state, PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, } + @property + def remote_allow_remote_enable(self) -> bool: + """Return if it's allowed to remotely activate remote.""" + allowed: bool = self._prefs.get(PREF_REMOTE_ALLOW_REMOTE_ENABLE, True) + return allowed + @property def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" @@ -241,6 +274,12 @@ def google_enabled(self) -> bool: google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] return google_enabled + @property + def google_connected(self) -> bool: + """Return if Google is connected.""" + google_connected: bool = self._prefs[PREF_GOOGLE_CONNECTED] + return google_connected + @property def google_report_state(self) -> bool: """Return if Google report state is enabled.""" @@ -338,6 +377,7 @@ def _empty_config(username: str) -> dict[str, Any]: PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, + PREF_GOOGLE_CONNECTED: False, PREF_GOOGLE_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, PREF_GOOGLE_ENTITY_CONFIGS: {}, PREF_GOOGLE_SETTINGS_VERSION: GOOGLE_SETTINGS_VERSION, @@ -345,5 +385,6 @@ def _empty_config(username: str) -> dict[str, Any]: PREF_INSTANCE_ID: uuid.uuid4().hex, PREF_GOOGLE_SECURE_DEVICES_PIN: None, PREF_REMOTE_DOMAIN: None, + PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 6f1e3c80bf7669..4bef2ac9ba364b 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -24,6 +24,10 @@ } }, "issues": { + "deprecated_tts_platform_config": { + "title": "The Cloud text-to-speech platform configuration is deprecated", + "description": "The whole `platform: cloud` entry under the `tts:` section in configuration.yaml is deprecated and should be removed. You can use the UI to change settings for the Cloud text-to-speech platform. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." + }, "deprecated_voice": { "title": "A deprecated voice was used", "fix_flow": { diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index 9a62f2d115c1f9..63b57d2fa3d376 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -19,7 +19,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | try: async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_subscription_info(cloud) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( ( "A timeout of %s was reached while trying to fetch subscription" @@ -40,7 +40,7 @@ async def async_migrate_paypal_agreement( try: async with asyncio.timeout(REQUEST_TIMEOUT): return await cloud_api.async_migrate_paypal_agreement(cloud) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "A timeout of %s was reached while trying to start agreement migration", REQUEST_TIMEOUT, diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index ba34ac7a9b0181..59ae5b22214d5c 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -20,8 +20,9 @@ Voice, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant, async_get_hass, 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 @@ -39,6 +40,27 @@ _LOGGER = logging.getLogger(__name__) +def _deprecated_platform(value: str) -> str: + """Validate if platform is deprecated.""" + if value == DOMAIN: + _LOGGER.warning( + "The cloud tts platform configuration is deprecated, " + "please remove it from your configuration " + "and use the UI to change settings instead" + ) + hass = async_get_hass() + async_create_issue( + hass, + DOMAIN, + "deprecated_tts_platform_config", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_tts_platform_config", + ) + return value + + def validate_lang(value: dict[str, Any]) -> dict[str, Any]: """Validate chosen gender or language.""" if (lang := value.get(CONF_LANG)) is None: @@ -58,6 +80,7 @@ def validate_lang(value: dict[str, Any]) -> dict[str, Any]: PLATFORM_SCHEMA = vol.All( TTS_PLATFORM_SCHEMA.extend( { + vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform), vol.Optional(CONF_LANG): str, vol.Optional(ATTR_GENDER): str, } diff --git a/homeassistant/components/cloudflare/icons.json b/homeassistant/components/cloudflare/icons.json new file mode 100644 index 00000000000000..6bf6d773fc3dc6 --- /dev/null +++ b/homeassistant/components/cloudflare/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "update_records": "mdi:dns" + } +} diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 69d2bd9e9041b2..40c8ca0c65a53d 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -76,12 +76,11 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non # Remove orphaned entities for entity in entities: currency = entity.unique_id.split("-")[-1] - if "xe" in entity.unique_id and currency not in config_entry.options.get( - CONF_EXCHANGE_RATES, [] - ): - registry.async_remove(entity.entity_id) - elif "wallet" in entity.unique_id and currency not in config_entry.options.get( - CONF_CURRENCIES, [] + if ( + "xe" in entity.unique_id + and currency not in config_entry.options.get(CONF_EXCHANGE_RATES, []) + or "wallet" in entity.unique_id + and currency not in config_entry.options.get(CONF_CURRENCIES, []) ): registry.async_remove(entity.entity_id) diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json index 515fe9f9abb742..dbb40b24fcc94c 100644 --- a/homeassistant/components/coinbase/manifest.json +++ b/homeassistant/components/coinbase/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@tombrien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coinbase", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["coinbase"], "requirements": ["coinbase==2.1.0"] diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 2cc3e206958728..e6095c9f925b36 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -139,7 +139,7 @@ async def async_extract_color_from_url(url): async with asyncio.timeout(10): response = await session.get(url) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Failed to get ColorThief image due to HTTPError: %s", err) return None diff --git a/homeassistant/components/color_extractor/icons.json b/homeassistant/components/color_extractor/icons.json new file mode 100644 index 00000000000000..07b449ffc5423b --- /dev/null +++ b/homeassistant/components/color_extractor/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "turn_on": "mdi:lightbulb-on" + } +} diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py index ef974b8f3edff6..195bfa97b7d086 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -123,7 +123,7 @@ async def async_update(self) -> None: else: self._attr_native_value = None - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Could not get data from ComEd API: %s", err) except (ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 06db68a2444f09..2cf7a145eee3ff 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -13,6 +13,7 @@ BRIDGE_PLATFORMS = [ Platform.CLIMATE, Platform.COVER, + Platform.HUMIDIFIER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 4ff75ba5307483..fe23cb1f5d3cd4 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -83,8 +83,8 @@ async def _async_update_data(self) -> dict[str, Any]: return await self._async_update_system_data() except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err: raise UpdateFailed(repr(err)) from err - except exceptions.CannotAuthenticate: - raise ConfigEntryAuthFailed + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err @abstractmethod async def _async_update_system_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py new file mode 100644 index 00000000000000..8ec2e9fd28b019 --- /dev/null +++ b/homeassistant/components/comelit/humidifier.py @@ -0,0 +1,212 @@ +"""Support for humidifiers.""" +from __future__ import annotations + +from enum import StrEnum +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import CLIMATE + +from homeassistant.components.humidifier import ( + MODE_AUTO, + MODE_NORMAL, + HumidifierAction, + HumidifierDeviceClass, + HumidifierEntity, + HumidifierEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + + +class HumidifierComelitMode(StrEnum): + """Serial Bridge humidifier modes.""" + + AUTO = "A" + OFF = "O" + LOWER = "L" + UPPER = "U" + + +class HumidifierComelitCommand(StrEnum): + """Serial Bridge humidifier commands.""" + + OFF = "off" + ON = "on" + MANUAL = "man" + SET = "set" + AUTO = "auto" + LOWER = "lower" + UPPER = "upper" + + +MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = { + MODE_AUTO: HumidifierComelitCommand.AUTO, + MODE_NORMAL: HumidifierComelitCommand.MANUAL, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit humidifiers.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitHumidifierEntity] = [] + for device in coordinator.data[CLIMATE].values(): + entities.append( + ComelitHumidifierEntity( + coordinator, + device, + config_entry.entry_id, + active_mode=HumidifierComelitMode.LOWER, + active_action=HumidifierAction.DRYING, + set_command=HumidifierComelitCommand.LOWER, + device_class=HumidifierDeviceClass.DEHUMIDIFIER, + ) + ) + entities.append( + ComelitHumidifierEntity( + coordinator, + device, + config_entry.entry_id, + active_mode=HumidifierComelitMode.UPPER, + active_action=HumidifierAction.HUMIDIFYING, + set_command=HumidifierComelitCommand.UPPER, + device_class=HumidifierDeviceClass.HUMIDIFIER, + ), + ) + + async_add_entities(entities) + + +class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity): + """Humidifier device.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = [MODE_NORMAL, MODE_AUTO] + _attr_min_humidity = 10 + _attr_max_humidity = 90 + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + active_mode: HumidifierComelitMode, + active_action: HumidifierAction, + set_command: HumidifierComelitCommand, + device_class: HumidifierDeviceClass, + ) -> None: + """Init light entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}" + self._attr_device_info = coordinator.platform_device_info(device, device_class) + self._attr_device_class = device_class + self._attr_translation_key = device_class.value + self._active_mode = active_mode + self._active_action = active_action + self._set_command = set_command + + @property + def _humidifier(self) -> list[Any]: + """Return humidifier device data.""" + # CLIMATE has a 2 item tuple: + # - first for Clima + # - second for Humidifier + return self.coordinator.data[CLIMATE][self._device.index].val[1] + + @property + def _api_mode(self) -> str: + """Return device mode.""" + # Values from API: "O", "L", "U" + return self._humidifier[2] + + @property + def _api_active(self) -> bool: + "Return device active/idle." + return self._humidifier[1] + + @property + def _api_automatic(self) -> bool: + """Return device in automatic/manual mode.""" + return self._humidifier[3] == HumidifierComelitMode.AUTO + + @property + def target_humidity(self) -> int: + """Set target humidity.""" + return self._humidifier[4] / 10 + + @property + def current_humidity(self) -> int: + """Return current humidity.""" + return self._humidifier[0] / 10 + + @property + def is_on(self) -> bool | None: + """Return true is humidifier is on.""" + return self._api_mode == self._active_mode + + @property + def mode(self) -> str | None: + """Return current mode.""" + return MODE_AUTO if self._api_automatic else MODE_NORMAL + + @property + def action(self) -> HumidifierAction | None: + """Return current action.""" + + if self._api_mode == HumidifierComelitMode.OFF: + return HumidifierAction.OFF + + if self._api_active and self._api_mode == self._active_mode: + return self._active_action + + return HumidifierAction.IDLE + + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + if self.mode == HumidifierComelitMode.OFF: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_while_off", + ) + + await self.coordinator.api.set_humidity_status( + self._device.index, HumidifierComelitCommand.MANUAL + ) + await self.coordinator.api.set_humidity_status( + self._device.index, HumidifierComelitCommand.SET, humidity + ) + + async def async_set_mode(self, mode: str) -> None: + """Set humidifier mode.""" + await self.coordinator.api.set_humidity_status( + self._device.index, MODE_TO_ACTION[mode] + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + await self.coordinator.api.set_humidity_status( + self._device.index, self._set_command + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + await self.coordinator.api.set_humidity_status( + self._device.index, HumidifierComelitCommand.OFF + ) diff --git a/homeassistant/components/comelit/icons.json b/homeassistant/components/comelit/icons.json new file mode 100644 index 00000000000000..6c42d20de657f8 --- /dev/null +++ b/homeassistant/components/comelit/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "zone_status": { + "default": "mdi:shield-check" + } + } + } +} diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 7deb3d49624b6b..a1743bff12d745 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -6,7 +6,7 @@ from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON -from homeassistant.components.light import LightEntity +from homeassistant.components.light import ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,8 +34,10 @@ async def async_setup_entry( class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): """Light device.""" + _attr_color_mode = ColorMode.ONOFF _attr_has_entity_name = True _attr_name = None + _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( self, diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index bbbb4efe7d620e..d93ec349bba102 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.8.3"] + "requirements": ["aiocomelit==0.9.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 66b04e6ae98fc9..7cdb0535f8c8ce 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -35,7 +35,6 @@ translation_key="zone_status", name=None, device_class=SensorDeviceClass.ENUM, - icon="mdi:shield-check", options=[zone_state.value for zone_state in AlarmZoneState], ), ) diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index dac8bc4123d31f..14d947c7323601 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -46,7 +46,18 @@ "rest": "Rest", "sabotated": "Sabotated" } + }, + "humidifier": { + "name": "Humidifier" + }, + "dehumidifier": { + "name": "Dehumidifier" } } + }, + "exceptions": { + "humidity_while_off": { + "message": "Cannot change humidity while off" + } } } diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 05e5d3b9a2de9e..fbeb5904a1a5eb 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -26,6 +26,7 @@ _DataT = TypeVar("_DataT", dict[str, dict[str, Any]], list[dict[str, Any]]) DOMAIN = "config" + SECTIONS = ( "area_registry", "auth", @@ -35,6 +36,8 @@ "core", "device_registry", "entity_registry", + "floor_registry", + "label_registry", "script", "scene", ) @@ -50,24 +53,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, "config", "config", "hass:cog", require_admin=True ) - async def setup_panel(panel_name: str) -> None: - """Set up a panel.""" + for panel_name in SECTIONS: panel = importlib.import_module(f".{panel_name}", __name__) - if not panel: - return - - success = await panel.async_setup(hass) - - if success: + if panel.async_setup(hass): key = f"{DOMAIN}.{panel_name}" hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) - tasks = [asyncio.create_task(setup_panel(panel_name)) for panel_name in SECTIONS] - - if tasks: - await asyncio.wait(tasks) - return True diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index c8dc7450183dc2..31841717109375 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -10,7 +10,8 @@ from homeassistant.helpers.area_registry import AreaEntry, async_get -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Area Registry views.""" websocket_api.async_register_command(hass, websocket_list_areas) websocket_api.async_register_command(hass, websocket_create_area) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 355dc739a9c00d..0409bf0f0f4c8d 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -7,7 +7,7 @@ from homeassistant.auth.models import User from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback WS_TYPE_LIST = "config/auth/list" SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( @@ -20,7 +20,8 @@ ) -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command( hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index c8b7e91f5a7654..0c58cad536e7c3 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -7,11 +7,12 @@ from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" websocket_api.async_register_command(hass, websocket_create) websocket_api.async_register_command(hass, websocket_delete) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 02131fe2169699..cf637b0aa236b6 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -11,13 +11,14 @@ ) from homeassistant.config import AUTOMATION_CONFIG_PATH from homeassistant.const import CONF_ID, SERVICE_RELOAD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Set up the Automation config API.""" async def hook(action: str, config_key: str) -> None: diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index b19c010123225f..52904cb8d358fa 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -21,16 +21,18 @@ FlowManagerResourceView, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.json import json_fragment from homeassistant.loader import ( Integration, IntegrationNotFound, async_get_config_flows, - async_get_integration, async_get_integrations, + async_get_loaded_integration, ) -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) @@ -68,7 +70,10 @@ async def get(self, request: web.Request) -> web.Response: type_filter = None if "type" in request.query: type_filter = [request.query["type"]] - return self.json(await async_matching_config_entries(hass, type_filter, domain)) + fragments = await _async_matching_config_entries_json_fragments( + hass, type_filter, domain + ) + return self.json(fragments) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -128,7 +133,8 @@ def _prepare_config_flow_result_json( return prepare_result_json(result) data = result.copy() - data["result"] = entry_json(result["result"]) + entry: config_entries.ConfigEntry = data["result"] + data["result"] = entry.as_json_fragment data.pop("data") data.pop("context") return data @@ -157,6 +163,12 @@ async def post(self, request: web.Request) -> web.Response: status=HTTPStatus.BAD_REQUEST, ) + def get_context(self, data: dict[str, Any]) -> dict[str, Any]: + """Return context.""" + context = super().get_context(data) + context["source"] = config_entries.SOURCE_USER + return context + def _prepare_result_json( self, result: data_entry_flow.FlowResult ) -> data_entry_flow.FlowResult: @@ -305,7 +317,7 @@ async def config_entry_get_single( if entry is None: return - result = {"config_entry": entry_json(entry)} + result = {"config_entry": entry.as_json_fragment} connection.send_result(msg["id"], result) @@ -340,7 +352,7 @@ async def config_entry_update( hass.config_entries.async_update_entry(entry, **changes) result = { - "config_entry": entry_json(entry), + "config_entry": entry.as_json_fragment, "require_restart": False, } @@ -446,12 +458,10 @@ async def config_entries_get( msg: dict[str, Any], ) -> None: """Return matching config entries by type and/or domain.""" - connection.send_result( - msg["id"], - await async_matching_config_entries( - hass, msg.get("type_filter"), msg.get("domain") - ), + fragments = await _async_matching_config_entries_json_fragments( + hass, msg.get("type_filter"), msg.get("domain") ) + connection.send_result(msg["id"], fragments) @websocket_api.websocket_command( @@ -469,12 +479,13 @@ async def config_entries_subscribe( """Subscribe to config entry updates.""" type_filter = msg.get("type_filter") - async def async_forward_config_entry_changes( + @callback + def async_forward_config_entry_changes( change: config_entries.ConfigEntryChange, entry: config_entries.ConfigEntry ) -> None: """Forward config entry state events to websocket.""" if type_filter: - integration = await async_get_integration(hass, entry.domain) + integration = async_get_loaded_integration(hass, entry.domain) if integration.integration_type not in type_filter: return @@ -484,13 +495,15 @@ async def async_forward_config_entry_changes( [ { "type": change, - "entry": entry_json(entry), + "entry": entry.as_json_fragment, } ], ) ) - current_entries = await async_matching_config_entries(hass, type_filter, None) + current_entries = await _async_matching_config_entries_json_fragments( + hass, type_filter, None + ) connection.subscriptions[msg["id"]] = async_dispatcher_connect( hass, config_entries.SIGNAL_CONFIG_ENTRY_CHANGED, @@ -504,17 +517,17 @@ async def async_forward_config_entry_changes( ) -async def async_matching_config_entries( +async def _async_matching_config_entries_json_fragments( hass: HomeAssistant, type_filter: list[str] | None, domain: str | None -) -> list[dict[str, Any]]: +) -> list[json_fragment]: """Return matching config entries by type and/or domain.""" - kwargs = {} if domain: - kwargs["domain"] = domain - entries = hass.config_entries.async_entries(**kwargs) + entries = hass.config_entries.async_entries(domain) + else: + entries = hass.config_entries.async_entries() if not type_filter: - return [entry_json(entry) for entry in entries] + return [entry.as_json_fragment for entry in entries] integrations: dict[str, Integration] = {} # Fetch all the integrations so we can check their type @@ -534,7 +547,7 @@ async def async_matching_config_entries( filter_is_not_helper = type_filter != ["helper"] filter_set = set(type_filter) return [ - entry_json(entry) + entry.as_json_fragment for entry in entries # If the filter is not 'helper', we still include the integration # even if its not returned from async_get_integrations for backwards @@ -545,22 +558,3 @@ async def async_matching_config_entries( ) or (filter_is_not_helper and entry.domain not in integrations) ] - - -@callback -def entry_json(entry: config_entries.ConfigEntry) -> dict[str, Any]: - """Return JSON value of a config entry.""" - return { - "entry_id": entry.entry_id, - "domain": entry.domain, - "title": entry.title, - "source": entry.source, - "state": entry.state.value, - "supports_options": entry.supports_options, - "supports_remove_device": entry.supports_remove_device or False, - "supports_unload": entry.supports_unload or False, - "pref_disable_new_entities": entry.pref_disable_new_entities, - "pref_disable_polling": entry.pref_disable_polling, - "disabled_by": entry.disabled_by, - "reason": entry.reason, - } diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index e6eac5f6e8e465..c3e070a3751039 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -9,13 +9,14 @@ from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.sensor import async_update_suggested_units -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import check_config, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, unit_system -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Set up the Hassbian config.""" hass.http.register_view(CheckConfigView) websocket_api.async_register_command(hass, websocket_update_config) diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index dfa55b02c306d8..7bd76310929db8 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -17,7 +17,8 @@ ) -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Device Registry views.""" websocket_api.async_register_command(hass, websocket_list_devices) @@ -47,15 +48,14 @@ def websocket_list_devices( f'"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.json_repr for entry in registry.devices.values() if entry.json_repr is not None - ) - + b"]}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}")) connection.send_message(msg_json) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index f1c1fadc14497d..66a1ceeba6991a 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -18,7 +18,8 @@ from homeassistant.helpers.json import json_dumps -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Enable the Entity Registry views.""" websocket_api.async_register_command(hass, websocket_get_entities) @@ -45,15 +46,14 @@ def websocket_list_entities( '"success":true,"result": [' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.partial_json_repr for entry in registry.entities.values() if entry.partial_json_repr is not None - ) - + b"]}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}")) connection.send_message(msg_json) @@ -77,15 +77,14 @@ def websocket_list_entities_for_display( f'"result":{{"entity_categories":{_ENTITY_CATEGORIES_JSON},"entities":[' ).encode() # Concatenate cached entity registry item JSON serializations - msg_json = ( - msg_json_prefix - + b",".join( + inner = b",".join( + [ entry.display_json_repr for entry in registry.entities.values() if entry.disabled_by is None and entry.display_json_repr is not None - ) - + b"]}}" + ] ) + msg_json = b"".join((msg_json_prefix, inner, b"]}}")) connection.send_message(msg_json) diff --git a/homeassistant/components/config/floor_registry.py b/homeassistant/components/config/floor_registry.py new file mode 100644 index 00000000000000..4b3ffbd4575fd9 --- /dev/null +++ b/homeassistant/components/config/floor_registry.py @@ -0,0 +1,126 @@ +"""Websocket API to interact with the floor registry.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.floor_registry import FloorEntry, async_get + + +@callback +def async_setup(hass: HomeAssistant) -> bool: + """Register the floor registry WS commands.""" + websocket_api.async_register_command(hass, websocket_list_floors) + websocket_api.async_register_command(hass, websocket_create_floor) + websocket_api.async_register_command(hass, websocket_delete_floor) + websocket_api.async_register_command(hass, websocket_update_floor) + return True + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/floor_registry/list", + } +) +@callback +def websocket_list_floors( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle list floors command.""" + registry = async_get(hass) + connection.send_result( + msg["id"], + [_entry_dict(entry) for entry in registry.async_list_floors()], + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/floor_registry/create", + vol.Required("name"): str, + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("level"): int, + } +) +@websocket_api.require_admin +@callback +def websocket_create_floor( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create floor command.""" + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") + + try: + entry = registry.async_create(**data) + except ValueError as err: + connection.send_error(msg["id"], "invalid_info", str(err)) + else: + connection.send_result(msg["id"], _entry_dict(entry)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/floor_registry/delete", + vol.Required("floor_id"): str, + } +) +@websocket_api.require_admin +@callback +def websocket_delete_floor( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Delete floor command.""" + registry = async_get(hass) + + try: + registry.async_delete(msg["floor_id"]) + except KeyError: + connection.send_error(msg["id"], "invalid_info", "Floor ID doesn't exist") + else: + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/floor_registry/update", + vol.Required("floor_id"): str, + vol.Optional("icon"): vol.Any(str, None), + vol.Optional("level"): int, + vol.Optional("name"): str, + } +) +@websocket_api.require_admin +@callback +def websocket_update_floor( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle update floor websocket command.""" + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") + + try: + entry = registry.async_update(**data) + except ValueError as err: + connection.send_error(msg["id"], "invalid_info", str(err)) + else: + connection.send_result(msg["id"], _entry_dict(entry)) + + +@callback +def _entry_dict(entry: FloorEntry) -> dict[str, Any]: + """Convert entry to API format.""" + return { + "floor_id": entry.floor_id, + "icon": entry.icon, + "level": entry.level, + "name": entry.name, + } diff --git a/homeassistant/components/config/label_registry.py b/homeassistant/components/config/label_registry.py new file mode 100644 index 00000000000000..7ea80231e82da8 --- /dev/null +++ b/homeassistant/components/config/label_registry.py @@ -0,0 +1,130 @@ +"""Websocket API to interact with the label registry.""" +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.label_registry import LabelEntry, async_get + + +@callback +def async_setup(hass: HomeAssistant) -> bool: + """Register the Label Registry WS commands.""" + websocket_api.async_register_command(hass, websocket_list_labels) + websocket_api.async_register_command(hass, websocket_create_label) + websocket_api.async_register_command(hass, websocket_delete_label) + websocket_api.async_register_command(hass, websocket_update_label) + return True + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/label_registry/list", + } +) +@callback +def websocket_list_labels( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle list labels command.""" + registry = async_get(hass) + connection.send_result( + msg["id"], + [_entry_dict(entry) for entry in registry.async_list_labels()], + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/label_registry/create", + vol.Required("name"): str, + vol.Optional("color"): vol.Any(cv.color_hex, None), + vol.Optional("description"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(cv.icon, None), + } +) +@websocket_api.require_admin +@callback +def websocket_create_label( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Create label command.""" + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") + + try: + entry = registry.async_create(**data) + except ValueError as err: + connection.send_error(msg["id"], "invalid_info", str(err)) + else: + connection.send_result(msg["id"], _entry_dict(entry)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/label_registry/delete", + vol.Required("label_id"): str, + } +) +@websocket_api.require_admin +@callback +def websocket_delete_label( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Delete label command.""" + registry = async_get(hass) + + try: + registry.async_delete(msg["label_id"]) + except KeyError: + connection.send_error(msg["id"], "invalid_info", "Label ID doesn't exist") + else: + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "config/label_registry/update", + vol.Required("label_id"): str, + vol.Optional("color"): vol.Any(cv.color_hex, None), + vol.Optional("description"): vol.Any(str, None), + vol.Optional("icon"): vol.Any(cv.icon, None), + vol.Optional("name"): str, + } +) +@websocket_api.require_admin +@callback +def websocket_update_label( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle update label websocket command.""" + registry = async_get(hass) + + data = dict(msg) + data.pop("type") + data.pop("id") + + try: + entry = registry.async_update(**data) + except ValueError as err: + connection.send_error(msg["id"], "invalid_info", str(err)) + else: + connection.send_result(msg["id"], _entry_dict(entry)) + + +@callback +def _entry_dict(entry: LabelEntry) -> dict[str, Any]: + """Convert entry to API format.""" + return { + "color": entry.color, + "description": entry.description, + "icon": entry.icon, + "label_id": entry.label_id, + "name": entry.name, + } diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index efbfd73db05cfc..01bdce0c8bc7ac 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -7,13 +7,14 @@ 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, HomeAssistant +from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditIdBasedConfigView -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Set up the Scene config API.""" async def hook(action: str, config_key: str) -> None: diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index aa8a2a52d835e9..d181ad942869bb 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -10,13 +10,14 @@ ) from homeassistant.config import SCRIPT_CONFIG_PATH from homeassistant.const import SERVICE_RELOAD -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from . import ACTION_DELETE, EditKeyBasedConfigView -async def async_setup(hass: HomeAssistant) -> bool: +@callback +def async_setup(hass: HomeAssistant) -> bool: """Set up the script config API.""" async def hook(action: str, config_key: str) -> None: diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 06dc62d114b1c3..b93e586b7ca0b9 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Control4 integration.""" from __future__ import annotations -from asyncio import TimeoutError as asyncioTimeoutError import logging from aiohttp.client_exceptions import ClientError @@ -82,7 +81,7 @@ async def connect_to_director(self) -> bool: ) await director.getAllItemInfo() return True - except (Unauthorized, ClientError, asyncioTimeoutError): + except (Unauthorized, ClientError, TimeoutError): _LOGGER.error("Failed to connect to the Control4 controller") return False diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index e4317052b044bf..6f484941a3d96d 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.6.1", "home-assistant-intents==2024.2.2"] + "requirements": ["hassil==1.6.1", "home-assistant-intents==2024.2.28"] } diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py index 29fd5797124535..884d38c77dce00 100644 --- a/homeassistant/components/coolmaster/binary_sensor.py +++ b/homeassistant/components/coolmaster/binary_sensor.py @@ -37,7 +37,6 @@ class CoolmasterCleanFilter(CoolmasterEntity, BinarySensorEntity): translation_key="clean_filter", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:air-filter", ) @property diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py index e4dfb371a0b831..db9dd55ea0b8f2 100644 --- a/homeassistant/components/coolmaster/button.py +++ b/homeassistant/components/coolmaster/button.py @@ -32,7 +32,6 @@ class CoolmasterResetFilter(CoolmasterEntity, ButtonEntity): key="reset_filter", translation_key="reset_filter", entity_category=EntityCategory.CONFIG, - icon="mdi:air-filter", ) async def async_press(self) -> None: diff --git a/homeassistant/components/coolmaster/icons.json b/homeassistant/components/coolmaster/icons.json new file mode 100644 index 00000000000000..f69e60fdee3352 --- /dev/null +++ b/homeassistant/components/coolmaster/icons.json @@ -0,0 +1,19 @@ +{ + "entity": { + "binary_sensor": { + "clean_filter": { + "default": "mdi:air-filter" + } + }, + "button": { + "reset_filter": { + "default": "mdi:air-filter" + } + }, + "sensor": { + "error_code": { + "default": "mdi:alert" + } + } + } +} diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py index 5c6774e8c9238d..30b22f4f658182 100644 --- a/homeassistant/components/coolmaster/sensor.py +++ b/homeassistant/components/coolmaster/sensor.py @@ -32,7 +32,6 @@ class CoolmasterCleanFilter(CoolmasterEntity, SensorEntity): key="error_code", translation_key="error_code", entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:alert", ) @property diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index 9174e8399f3e74..dc8f722c7ed0c0 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -1,4 +1,6 @@ """Intents for the cover integration.""" + + from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER from homeassistant.core import HomeAssistant from homeassistant.helpers import intent diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py index 5eb05afd0149ef..0df83b24d70989 100644 --- a/homeassistant/components/cpuspeed/sensor.py +++ b/homeassistant/components/cpuspeed/sensor.py @@ -33,7 +33,6 @@ class CPUSpeedSensor(SensorEntity): """Representation of a CPU sensor.""" _attr_device_class = SensorDeviceClass.FREQUENCY - _attr_icon = "mdi:pulse" _attr_has_entity_name = True _attr_name = None _attr_native_unit_of_measurement = UnitOfFrequency.GIGAHERTZ diff --git a/homeassistant/components/crownstone/icons.json b/homeassistant/components/crownstone/icons.json new file mode 100644 index 00000000000000..fcc2822920b724 --- /dev/null +++ b/homeassistant/components/crownstone/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "light": { + "german_power_outlet": { + "default": "mdi:power-socket-de" + } + } + } +} diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index a140de59017494..a95238bcdbe53a 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -70,8 +70,8 @@ class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): Light platform is used to support dimming. """ - _attr_icon = "mdi:power-socket-de" _attr_name = None + _attr_translation_key = "german_power_outlet" def __init__( self, crownstone_data: Crownstone, usb: CrownstoneUart | None = None diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index e39fe97bc6c9b7..b8e87d2b200690 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -86,7 +86,7 @@ async def daikin_api_setup( device = await Appliance.factory( host, session, key=key, uuid=uuid, password=password ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.debug("Connection to %s timed out", host) raise ConfigEntryNotReady from err except ClientConnectionError as err: diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py index b79cc960fcea8a..abd2d78c7fb26d 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -89,7 +89,7 @@ async def _create_device( uuid=uuid, password=password, ) - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): self.host = None return self.async_show_form( step_id="user", diff --git a/homeassistant/components/daikin/icons.json b/homeassistant/components/daikin/icons.json new file mode 100644 index 00000000000000..99dfa8efdf5258 --- /dev/null +++ b/homeassistant/components/daikin/icons.json @@ -0,0 +1,26 @@ +{ + "entity": { + "sensor": { + "cool_energy_consumption": { + "default": "mdi:snowflake" + }, + "heat_energy_consumption": { + "default": "mdi:fire" + }, + "compressor_frequency": { + "default": "mdi:fan" + } + }, + "switch": { + "zone": { + "default": "mdi:home-circle" + }, + "streamer": { + "default": "mdi:air-filter" + }, + "toggle": { + "default": "mdi:power" + } + } + } +} diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 9e7a181ba325a8..b890ad823f7a21 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -94,7 +94,6 @@ class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysM DaikinSensorEntityDescription( key=ATTR_COOL_ENERGY, translation_key="cool_energy_consumption", - icon="mdi:snowflake", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, entity_registry_enabled_default=False, @@ -103,7 +102,6 @@ class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysM DaikinSensorEntityDescription( key=ATTR_HEAT_ENERGY, translation_key="heat_energy_consumption", - icon="mdi:fire", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, entity_registry_enabled_default=False, @@ -120,7 +118,6 @@ class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysM DaikinSensorEntityDescription( key=ATTR_COMPRESSOR_FREQUENCY, translation_key="compressor_frequency", - icon="mdi:fan", device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfFrequency.HERTZ, diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 8741898237ef94..dd157774d6eae4 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -11,9 +11,6 @@ from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi -ZONE_ICON = "mdi:home-circle" -STREAMER_ICON = "mdi:air-filter" -TOGGLE_ICON = "mdi:power" DAIKIN_ATTR_ADVANCED = "adv" DAIKIN_ATTR_STREAMER = "streamer" DAIKIN_ATTR_MODE = "mode" @@ -58,8 +55,8 @@ async def async_setup_entry( class DaikinZoneSwitch(SwitchEntity): """Representation of a zone.""" - _attr_icon = ZONE_ICON _attr_has_entity_name = True + _attr_translation_key = "zone" def __init__(self, api: DaikinApi, zone_id: int) -> None: """Initialize the zone.""" @@ -94,9 +91,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: class DaikinStreamerSwitch(SwitchEntity): """Streamer state.""" - _attr_icon = STREAMER_ICON _attr_name = "Streamer" _attr_has_entity_name = True + _attr_translation_key = "streamer" def __init__(self, api: DaikinApi) -> None: """Initialize streamer switch.""" @@ -127,8 +124,8 @@ async def async_turn_off(self, **kwargs: Any) -> None: class DaikinToggleSwitch(SwitchEntity): """Switch state.""" - _attr_icon = TOGGLE_ICON _attr_has_entity_name = True + _attr_translation_key = "toggle" def __init__(self, api: DaikinApi) -> None: """Initialize switch.""" diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index d3ed35643441d6..fc52557fa5a5d0 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.8.0"] + "requirements": ["debugpy==1.8.1"] } diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index c0361aa2bcac1e..99fa641236409b 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -103,7 +103,7 @@ async def async_step_user( async with asyncio.timeout(10): self.bridges = await deconz_discovery(session) - except (asyncio.TimeoutError, ResponseError): + except (TimeoutError, ResponseError): self.bridges = [] if LOGGER.isEnabledFor(logging.DEBUG): @@ -164,7 +164,7 @@ async def async_step_link( except LinkButtonNotPressed: errors["base"] = "linking_not_possible" - except (ResponseError, RequestError, asyncio.TimeoutError): + except (ResponseError, RequestError, TimeoutError): errors["base"] = "no_key" else: @@ -193,7 +193,7 @@ async def _create_entry(self) -> FlowResult: } ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="no_bridges") return self.async_create_entry( diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 156309c090375e..a9286cca112b8a 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -301,7 +301,10 @@ async def options_updated(self) -> None: entity_registry = er.async_get(self.hass) - for entity_id, deconz_id in self.deconz_ids.items(): + # Copy the ids since calling async_remove will modify the dict + # and will cause a runtime error because the dict size changes + # during iteration + for entity_id, deconz_id in self.deconz_ids.copy().items(): if deconz_id in deconz_ids and entity_registry.async_is_registered( entity_id ): @@ -360,6 +363,6 @@ async def get_deconz_session( LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) raise AuthenticationRequired from err - except (asyncio.TimeoutError, errors.RequestError, errors.ResponseError) as err: + except (TimeoutError, errors.RequestError, errors.ResponseError) as err: LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) raise CannotConnect from err diff --git a/homeassistant/components/deconz/icons.json b/homeassistant/components/deconz/icons.json new file mode 100644 index 00000000000000..5b22daee53f839 --- /dev/null +++ b/homeassistant/components/deconz/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "configure": "mdi:cog", + "device_refresh": "mdi:refresh", + "remove_orphaned_entries": "mdi:bookmark-remove" + } +} diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 27038a07ac3bb8..d618edc93f85ab 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -63,6 +63,7 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.CT: ColorMode.COLOR_TEMP, + LightColorMode.GRADIENT: ColorMode.XY, LightColorMode.HS: ColorMode.HS, LightColorMode.XY: ColorMode.XY, } @@ -164,6 +165,7 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): """Representation of a deCONZ light.""" TYPE = DOMAIN + _attr_color_mode = ColorMode.UNKNOWN def __init__(self, device: _LightDeviceT, gateway: DeconzGateway) -> None: """Set up light.""" diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index af1824e441ceb5..ef2f4a73c1bbdc 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==114"], + "requirements": ["pydeconz==115"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 63412242dd0b46..40f4d772670998 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from ssl import SSLError from deluge_client.client import DelugeRPCClient @@ -40,11 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.web_port = entry.data[CONF_WEB_PORT] try: await hass.async_add_executor_job(api.connect) - except ( - ConnectionRefusedError, - socket.timeout, - SSLError, - ) as ex: + except (ConnectionRefusedError, TimeoutError, SSLError) as ex: raise ConfigEntryNotReady("Connection to Deluge Daemon failed") from ex except Exception as ex: # pylint:disable=broad-except if type(ex).__name__ == "BadLoginError": diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 5de613500398f0..db2598e1f67520 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import socket from ssl import SSLError from typing import Any @@ -91,11 +90,7 @@ async def validate_input(self, user_input: dict[str, Any]) -> str | None: ) try: await self.hass.async_add_executor_job(api.connect) - except ( - ConnectionRefusedError, - socket.timeout, - SSLError, - ): + except (ConnectionRefusedError, TimeoutError, SSLError): return "cannot_connect" except Exception as ex: # pylint:disable=broad-except if type(ex).__name__ == "BadLoginError": diff --git a/homeassistant/components/deluge/coordinator.py b/homeassistant/components/deluge/coordinator.py index 9b0d5907b1a198..7a3e840ff95f7f 100644 --- a/homeassistant/components/deluge/coordinator.py +++ b/homeassistant/components/deluge/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import timedelta -import socket from ssl import SSLError from typing import Any @@ -52,7 +51,7 @@ async def _async_update_data(self) -> dict[Platform, dict[str, Any]]: ) except ( ConnectionRefusedError, - socket.timeout, + TimeoutError, SSLError, FailedToReconnectException, ) as ex: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 73cae4a64b1636..644c4cb78604af 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -90,6 +90,7 @@ class BaseDemoFan(FanEntity): """A demonstration fan component that uses legacy fan speeds.""" _attr_should_poll = False + _attr_translation_key = "demo" def __init__( self, diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json index 79c18bc0a2ec74..9c746c633d42a5 100644 --- a/homeassistant/components/demo/icons.json +++ b/homeassistant/components/demo/icons.json @@ -23,6 +23,21 @@ } } }, + "fan": { + "demo": { + "state_attributes": { + "preset_mode": { + "default": "mdi:circle-medium", + "state": { + "auto": "mdi:fan-auto", + "sleep": "mdi:bed", + "smart": "mdi:brain", + "on": "mdi:power" + } + } + } + } + }, "number": { "volume": { "default": "mdi:volume-high" diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index 555760a5af9546..aa5554e9fcc7cd 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -46,6 +46,20 @@ } } }, + "fan": { + "demo": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]", + "smart": "Smart", + "on": "[%key:common::state::on%]" + } + } + } + } + }, "event": { "push": { "state_attributes": { diff --git a/homeassistant/components/denonavr/icons.json b/homeassistant/components/denonavr/icons.json new file mode 100644 index 00000000000000..ec6bc0854f9008 --- /dev/null +++ b/homeassistant/components/denonavr/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_command": "mdi:console", + "set_dynamic_eq": "mdi:tune", + "update_audyssey": "mdi:waveform" + } +} diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 0ba8caed6c585e..d595c7616ba139 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -4,9 +4,10 @@ "codeowners": ["@ol-iver", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", + "import_executor": true, "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.4"], + "requirements": ["denonavr==0.11.6"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 125fec7caaa9d1..0002b04bd62fc2 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -21,6 +21,7 @@ AvrCommandError, AvrForbiddenError, AvrNetworkError, + AvrProcessingError, AvrTimoutError, DenonAvrError, ) @@ -201,6 +202,16 @@ async def wrapper( self._receiver.host, ) self._attr_available = False + except AvrProcessingError: + available = True + if self.available: + _LOGGER.warning( + ( + "Update of Denon AVR receiver at host %s not complete. " + "Device is still available" + ), + self._receiver.host, + ) except AvrForbiddenError: available = False if self.available: @@ -274,8 +285,6 @@ def __init__( and MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - self._telnet_was_healthy: bool | None = None - async def _telnet_callback(self, zone: str, event: str, parameter: str) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine @@ -306,24 +315,13 @@ async def async_update(self) -> None: """Get the latest status information from device.""" receiver = self._receiver - # We can only skip the update if telnet was healthy after - # the last update and is still healthy now to ensure that - # we don't miss any state changes while telnet is down - # or reconnecting. - if ( - telnet_is_healthy := receiver.telnet_connected and receiver.telnet_healthy - ) and self._telnet_was_healthy: + # We skip the update if telnet is healthy. + # When telnet recovers it automatically updates all properties. + if receiver.telnet_connected and receiver.telnet_healthy: return - # if async_update raises an exception, we don't want to skip the next update - # so we set _telnet_was_healthy to None here and only set it to the value - # before the update if the update was successful - self._telnet_was_healthy = None - await receiver.async_update() - self._telnet_was_healthy = telnet_is_healthy - if self._update_audyssey: await receiver.async_update_audyssey() @@ -453,9 +451,6 @@ async def async_media_next_track(self) -> None: @async_log_errors async def async_select_source(self, source: str) -> None: """Select input source.""" - # Ensure that the AVR is turned on, which is necessary for input - # switch to work. - await self.async_turn_on() await self._receiver.async_set_input_func(source) @async_log_errors diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 68d05c19f67763..2bf87343c72554 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -247,9 +247,9 @@ async def async_get_device_automations( match_device_ids = set(device_ids or device_registry.devices) combined_results: dict[str, list[dict[str, Any]]] = {} - for entry in entity_registry.entities.values(): - if not entry.disabled_by and entry.device_id in match_device_ids: - device_entities_domains.setdefault(entry.device_id, set()).add(entry.domain) + for device_id in match_device_ids: + for entry in entity_registry.entities.get_entries_for_device_id(device_id): + device_entities_domains.setdefault(device_id, set()).add(entry.domain) for device_id in match_device_ids: combined_results[device_id] = [] diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index a17972526cfa64..e1a8058d819f09 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -714,21 +714,17 @@ async def async_setup_tracked_device(self) -> None: This method is a coroutine. """ - - async def async_init_single_device(dev: Device) -> None: - """Init a single device_tracker entity.""" - await dev.async_added_to_hass() - dev.async_write_ha_state() - - tasks: list[asyncio.Task] = [] for device in self.devices.values(): if device.track and not device.last_seen: - tasks.append( - self.hass.async_create_task(async_init_single_device(device)) - ) - - if tasks: - await asyncio.wait(tasks) + # async_added_to_hass is unlikely to suspend so + # do not gather here to avoid unnecessary overhead + # of creating a task per device. + # + # We used to have the overhead of potentially loading + # restore state for each device here, but RestoreState + # is always loaded ahead of time now. + await device.async_added_to_hass() + device.async_write_ha_state() class Device(RestoreEntity): diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 35b79b57f1d498..cf8358b69a35ea 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -52,7 +52,6 @@ class DevoloBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:router-network", value_func=_is_connected_to_router, ), } diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 9b3dd75ef98898..eba1ad05157852 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -40,12 +40,11 @@ class DevoloButtonEntityDescription( IDENTIFY: DevoloButtonEntityDescription( key=IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:led-on", + device_class=ButtonDeviceClass.IDENTIFY, press_func=lambda device: device.plcnet.async_identify_device_start(), # type: ignore[union-attr] ), PAIRING: DevoloButtonEntityDescription( key=PAIRING, - icon="mdi:plus-network-outline", press_func=lambda device: device.plcnet.async_pair_device(), # type: ignore[union-attr] ), RESTART: DevoloButtonEntityDescription( @@ -56,7 +55,6 @@ class DevoloButtonEntityDescription( ), START_WPS: DevoloButtonEntityDescription( key=START_WPS, - icon="mdi:wifi-plus", press_func=lambda device: device.device.async_start_wps(), # type: ignore[union-attr] ), } diff --git a/homeassistant/components/devolo_home_network/icons.json b/homeassistant/components/devolo_home_network/icons.json new file mode 100644 index 00000000000000..816d0e36d038a8 --- /dev/null +++ b/homeassistant/components/devolo_home_network/icons.json @@ -0,0 +1,36 @@ +{ + "entity": { + "binary_sensor": { + "connected_to_router": { + "default": "mdi:router-network" + } + }, + "button": { + "pairing": { + "default": "mdi:plus-network-outline" + }, + "start_wps": { + "default": "mdi:wifi-plus" + } + }, + "sensor": { + "connected_plc_devices": { + "default": "mdi:lan" + }, + "connected_wifi_clients": { + "default": "mdi:wifi" + }, + "neighboring_wifi_networks": { + "default": "mdi:wifi-marker" + } + }, + "switch": { + "switch_guest_wifi": { + "default": "mdi:wifi" + }, + "switch_leds": { + "default": "mdi:led-off" + } + } + } +} diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 66395e3a465702..750bb9ad13d889 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -68,14 +68,12 @@ class DevoloSensorEntityDescription( key=CONNECTED_PLC_DEVICES, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lan", value_func=lambda data: len( {device.mac_address_from for device in data.data_rates} ), ), CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( key=CONNECTED_WIFI_CLIENTS, - icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, value_func=len, ), @@ -83,7 +81,6 @@ class DevoloSensorEntityDescription( key=NEIGHBORING_WIFI_NETWORKS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:wifi-marker", value_func=len, ), PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 99c23f77d35dfe..af0569a016f2ed 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -42,7 +42,6 @@ class DevoloSwitchEntityDescription( SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet]( key=SWITCH_GUEST_WIFI, - icon="mdi: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] @@ -50,7 +49,6 @@ class DevoloSwitchEntityDescription( SWITCH_LEDS: DevoloSwitchEntityDescription[bool]( key=SWITCH_LEDS, entity_category=EntityCategory.CONFIG, - icon="mdi:led-off", 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/dexcom/const.py b/homeassistant/components/dexcom/const.py index cb75f3bd500cff..8712eeb10ad5c0 100644 --- a/homeassistant/components/dexcom/const.py +++ b/homeassistant/components/dexcom/const.py @@ -3,7 +3,6 @@ DOMAIN = "dexcom" PLATFORMS = [Platform.SENSOR] -GLUCOSE_VALUE_ICON = "mdi:diabetes" GLUCOSE_TREND_ICON = [ "mdi:help", diff --git a/homeassistant/components/dexcom/icons.json b/homeassistant/components/dexcom/icons.json new file mode 100644 index 00000000000000..9d0b3534e17276 --- /dev/null +++ b/homeassistant/components/dexcom/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "glucose_value": { + "default": "mdi:diabetes" + } + } + } +} diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py index 126d946e57d55b..592419abc1b437 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -12,7 +12,7 @@ DataUpdateCoordinator, ) -from .const import COORDINATOR, DOMAIN, GLUCOSE_TREND_ICON, GLUCOSE_VALUE_ICON, MG_DL +from .const import COORDINATOR, DOMAIN, GLUCOSE_TREND_ICON, MG_DL async def async_setup_entry( @@ -55,7 +55,6 @@ def __init__( class DexcomGlucoseValueSensor(DexcomSensorEntity): """Representation of a Dexcom glucose value sensor.""" - _attr_icon = GLUCOSE_VALUE_ICON _attr_translation_key = "glucose_value" def __init__( diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index b8a12a937e33b2..ebd0629950ec7c 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -3,18 +3,17 @@ from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable, Iterable -import contextlib +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache +import itertools import logging -import os import re -import threading -from typing import TYPE_CHECKING, Any, Final, cast +from typing import Any, Final +import aiodhcpwatcher from aiodiscover import DiscoverHosts from aiodiscover.discovery import ( HOSTNAME as DISCOVERY_HOSTNAME, @@ -22,8 +21,6 @@ MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) from cached_ipaddress import cached_ip_addresses -from scapy.config import conf -from scapy.error import Scapy_Exception from homeassistant import config_entries from homeassistant.components.device_tracker import ( @@ -60,20 +57,13 @@ from .const import DOMAIN -if TYPE_CHECKING: - from scapy.packet import Packet - from scapy.sendrecv import AsyncSniffer - CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) FILTER = "udp and (port 67 or 68)" -REQUESTED_ADDR = "requested_addr" -MESSAGE_TYPE = "message-type" HOSTNAME: Final = "hostname" MAC_ADDRESS: Final = "macaddress" IP_ADDRESS: Final = "ip" REGISTERED_DEVICES: Final = "registered_devices" -DHCP_REQUEST = 3 SCAN_INTERVAL = timedelta(minutes=60) @@ -89,32 +79,79 @@ class DhcpServiceInfo(BaseServiceInfo): macaddress: str +@dataclass(slots=True) +class DhcpMatchers: + """Prepared info from dhcp entries.""" + + registered_devices_domains: set[str] + no_oui_matchers: dict[str, list[DHCPMatcher]] + oui_matchers: dict[str, list[DHCPMatcher]] + + +def async_index_integration_matchers( + integration_matchers: list[DHCPMatcher], +) -> DhcpMatchers: + """Index the integration matchers. + + We have three types of matchers: + + 1. Registered devices + 2. Devices with no OUI - index by first char of lower() hostname + 3. Devices with OUI - index by OUI + """ + registered_devices_domains: set[str] = set() + no_oui_matchers: dict[str, list[DHCPMatcher]] = {} + oui_matchers: dict[str, list[DHCPMatcher]] = {} + for matcher in integration_matchers: + domain = matcher["domain"] + if REGISTERED_DEVICES in matcher: + registered_devices_domains.add(domain) + continue + + if mac_address := matcher.get(MAC_ADDRESS): + oui_matchers.setdefault(mac_address[:6], []).append(matcher) + continue + + if hostname := matcher.get(HOSTNAME): + first_char = hostname[0].lower() + no_oui_matchers.setdefault(first_char, []).append(matcher) + + return DhcpMatchers( + registered_devices_domains=registered_devices_domains, + no_oui_matchers=no_oui_matchers, + oui_matchers=oui_matchers, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the dhcp component.""" watchers: list[WatcherBase] = [] address_data: dict[str, dict[str, str]] = {} - integration_matchers = await async_get_dhcp(hass) + integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass)) # For the passive classes we need to start listening # for state changes and connect the dispatchers before # everything else starts up or we will miss events for passive_cls in (DeviceTrackerRegisteredWatcher, DeviceTrackerWatcher): passive_watcher = passive_cls(hass, address_data, integration_matchers) - await passive_watcher.async_start() + passive_watcher.async_start() watchers.append(passive_watcher) - async def _initialize(event: Event) -> None: + async def _async_initialize(event: Event) -> None: + await aiodhcpwatcher.async_init() + for active_cls in (DHCPWatcher, NetworkWatcher): active_watcher = active_cls(hass, address_data, integration_matchers) - await active_watcher.async_start() + active_watcher.async_start() watchers.append(active_watcher) - async def _async_stop(event: Event) -> None: + @callback + def _async_stop(event: Event) -> None: for watcher in watchers: - await watcher.async_stop() + watcher.async_stop() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _initialize) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_initialize) return True @@ -125,7 +162,7 @@ def __init__( self, hass: HomeAssistant, address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], + integration_matchers: DhcpMatchers, ) -> None: """Initialize class.""" super().__init__() @@ -133,24 +170,23 @@ def __init__( self.hass = hass self._integration_matchers = integration_matchers self._address_data = address_data + self._unsub: Callable[[], None] | None = None - @abstractmethod - async def async_stop(self) -> None: - """Stop the watcher.""" + @callback + def async_stop(self) -> None: + """Stop scanning for new devices on the network.""" + if self._unsub: + self._unsub() + self._unsub = None @abstractmethod - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Start the watcher.""" - def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None: - """Process a client.""" - self.hass.loop.call_soon_threadsafe( - self.async_process_client, ip_address, hostname, mac_address - ) - @callback def async_process_client( - self, ip_address: str, hostname: str, mac_address: str + self, ip_address: str, hostname: str, unformatted_mac_address: str ) -> None: """Process a client.""" if (made_ip_address := cached_ip_addresses(ip_address)) is None: @@ -166,6 +202,12 @@ def async_process_client( # Ignore self assigned addresses, loopback, invalid return + formatted_mac = format_mac(unformatted_mac_address) + # Historically, the MAC address was formatted without colons + # and since all consumers of this data are expecting it to be + # formatted without colons we will continue to do so + mac_address = formatted_mac.replace(":", "") + data = self._address_data.get(ip_address) if ( data @@ -189,28 +231,29 @@ def async_process_client( lowercase_hostname, ) - matched_domains = set() - device_domains = set() + matched_domains: set[str] = set() + matchers = self._integration_matchers + registered_devices_domains = matchers.registered_devices_domains dev_reg: DeviceRegistry = async_get(self.hass) if device := dev_reg.async_get_device( - connections={(CONNECTION_NETWORK_MAC, uppercase_mac)} + connections={(CONNECTION_NETWORK_MAC, formatted_mac)} ): for entry_id in device.config_entries: - if entry := self.hass.config_entries.async_get_entry(entry_id): - device_domains.add(entry.domain) - - for matcher in self._integration_matchers: + if ( + entry := self.hass.config_entries.async_get_entry(entry_id) + ) and entry.domain in registered_devices_domains: + matched_domains.add(entry.domain) + + oui = uppercase_mac[:6] + lowercase_hostname_first_char = ( + lowercase_hostname[0] if len(lowercase_hostname) else "" + ) + for matcher in itertools.chain( + matchers.no_oui_matchers.get(lowercase_hostname_first_char, ()), + matchers.oui_matchers.get(oui, ()), + ): domain = matcher["domain"] - - if matcher.get(REGISTERED_DEVICES) and domain not in device_domains: - continue - - if ( - matcher_mac := matcher.get(MAC_ADDRESS) - ) is not None and not _memorized_fnmatch(uppercase_mac, matcher_mac): - continue - if ( matcher_hostname := matcher.get(HOSTNAME) ) is not None and not _memorized_fnmatch( @@ -241,24 +284,23 @@ def __init__( self, hass: HomeAssistant, address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], + integration_matchers: DhcpMatchers, ) -> None: """Initialize class.""" super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None self._discover_hosts: DiscoverHosts | None = None self._discover_task: asyncio.Task | None = None - async def async_stop(self) -> None: + @callback + def async_stop(self) -> None: """Stop scanning for new devices on the network.""" - if self._unsub: - self._unsub() - self._unsub = None + super().async_stop() if self._discover_task: self._discover_task.cancel() self._discover_task = None - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Start scanning for new devices on the network.""" self._discover_hosts = DiscoverHosts() self._unsub = async_track_time_interval( @@ -283,30 +325,15 @@ async def async_discover(self) -> None: self.async_process_client( host[DISCOVERY_IP_ADDRESS], host[DISCOVERY_HOSTNAME], - _format_mac(host[DISCOVERY_MAC_ADDRESS]), + host[DISCOVERY_MAC_ADDRESS], ) class DeviceTrackerWatcher(WatcherBase): """Class to watch dhcp data from routers.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None - - async def async_stop(self) -> None: - """Stop watching for new device trackers.""" - if self._unsub: - self._unsub() - self._unsub = None - - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Stop watching for new device trackers.""" self._unsub = async_track_state_added_domain( self.hass, [DEVICE_TRACKER_DOMAIN], self._async_process_device_event @@ -339,29 +366,14 @@ def _async_process_device_state(self, state: State | None) -> None: if ip_address is None or mac_address is None: return - self.async_process_client(ip_address, hostname, _format_mac(mac_address)) + self.async_process_client(ip_address, hostname, mac_address) class DeviceTrackerRegisteredWatcher(WatcherBase): """Class to watch data from device tracker registrations.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._unsub: Callable[[], None] | None = None - - async def async_stop(self) -> None: - """Stop watching for device tracker registrations.""" - if self._unsub: - self._unsub() - self._unsub = None - - async def async_start(self) -> None: + @callback + def async_start(self) -> None: """Stop watching for device tracker registrations.""" self._unsub = async_dispatcher_connect( self.hass, CONNECTED_DEVICE_REGISTERED, self._async_process_device_data @@ -377,152 +389,23 @@ def _async_process_device_data(self, data: dict[str, str | None]) -> None: if ip_address is None or mac_address is None: return - self.async_process_client(ip_address, hostname, _format_mac(mac_address)) + self.async_process_client(ip_address, hostname, mac_address) class DHCPWatcher(WatcherBase): """Class to watch dhcp requests.""" - def __init__( - self, - hass: HomeAssistant, - address_data: dict[str, dict[str, str]], - integration_matchers: list[DHCPMatcher], - ) -> None: - """Initialize class.""" - super().__init__(hass, address_data, integration_matchers) - self._sniffer: AsyncSniffer | None = None - self._started = threading.Event() - - async def async_stop(self) -> None: - """Stop watching for new device trackers.""" - await self.hass.async_add_executor_job(self._stop) - - def _stop(self) -> None: - """Stop the thread.""" - if self._started.is_set(): - assert self._sniffer is not None - self._sniffer.stop() - - async def async_start(self) -> None: - """Start watching for dhcp packets.""" - await self.hass.async_add_executor_job(self._start) - - def _start(self) -> None: - """Start watching for dhcp packets.""" - # Local import because importing from scapy has side effects such as opening - # sockets - from scapy import arch # pylint: disable=import-outside-toplevel # noqa: F401 - from scapy.layers.dhcp import DHCP # pylint: disable=import-outside-toplevel - from scapy.layers.inet import IP # pylint: disable=import-outside-toplevel - from scapy.layers.l2 import Ether # pylint: disable=import-outside-toplevel - - # - # Importing scapy.sendrecv will cause a scapy resync which will - # import scapy.arch.read_routes which will import scapy.sendrecv - # - # We avoid this circular import by importing arch above to ensure - # the module is loaded and avoid the problem - # - from scapy.sendrecv import ( # pylint: disable=import-outside-toplevel - AsyncSniffer, - ) - - def _handle_dhcp_packet(packet: Packet) -> None: - """Process a dhcp packet.""" - if DHCP not in packet: - return - - options_dict = _dhcp_options_as_dict(packet[DHCP].options) - if options_dict.get(MESSAGE_TYPE) != DHCP_REQUEST: - # Not a DHCP request - return - - ip_address = options_dict.get(REQUESTED_ADDR) or cast(str, packet[IP].src) - assert isinstance(ip_address, str) - hostname = "" - if (hostname_bytes := options_dict.get(HOSTNAME)) and isinstance( - hostname_bytes, bytes - ): - with contextlib.suppress(AttributeError, UnicodeDecodeError): - hostname = hostname_bytes.decode() - mac_address = _format_mac(cast(str, packet[Ether].src)) - - if ip_address is not None and mac_address is not None: - self.process_client(ip_address, hostname, mac_address) - - # disable scapy promiscuous mode as we do not need it - conf.sniff_promisc = 0 - - try: - _verify_l2socket_setup(FILTER) - except (Scapy_Exception, OSError) as ex: - if os.geteuid() == 0: - _LOGGER.error("Cannot watch for dhcp packets: %s", ex) - else: - _LOGGER.debug( - "Cannot watch for dhcp packets without root or CAP_NET_RAW: %s", ex - ) - return - - try: - _verify_working_pcap(FILTER) - except (Scapy_Exception, ImportError) as ex: - _LOGGER.error( - "Cannot watch for dhcp packets without a functional packet filter: %s", - ex, - ) - return - - self._sniffer = AsyncSniffer( - filter=FILTER, - started_callback=self._started.set, - prn=_handle_dhcp_packet, - store=0, + @callback + def _async_process_dhcp_request(self, response: aiodhcpwatcher.DHCPRequest) -> None: + """Process a dhcp request.""" + self.async_process_client( + response.ip_address, response.hostname, response.mac_address ) - self._sniffer.start() - if self._sniffer.thread: - self._sniffer.thread.name = self.__class__.__name__ - - -def _dhcp_options_as_dict( - dhcp_options: Iterable[tuple[str, int | bytes | None]], -) -> dict[str, str | int | bytes | None]: - """Extract data from packet options as a dict.""" - return {option[0]: option[1] for option in dhcp_options if len(option) >= 2} - - -def _format_mac(mac_address: str) -> str: - """Format a mac address for matching.""" - return format_mac(mac_address).replace(":", "") - - -def _verify_l2socket_setup(cap_filter: str) -> None: - """Create a socket using the scapy configured l2socket. - - Try to create the socket - to see if we have permissions - since AsyncSniffer will do it another - thread so we will not be able to capture - any permission or bind errors. - """ - conf.L2socket(filter=cap_filter) - - -def _verify_working_pcap(cap_filter: str) -> None: - """Verify we can create a packet filter. - - If we cannot create a filter we will be listening for - all traffic which is too intensive. - """ - # Local import because importing from scapy has side effects such as opening - # sockets - from scapy.arch.common import ( # pylint: disable=import-outside-toplevel - compile_filter, - ) - - compile_filter(cap_filter) + @callback + def async_start(self) -> None: + """Start watching for dhcp packets.""" + self._unsub = aiodhcpwatcher.start(self._async_process_dhcp_request) @lru_cache(maxsize=4096, typed=True) diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index f190f0ab10e551..d609e9ec7ae1f4 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -3,13 +3,20 @@ "name": "DHCP Discovery", "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/dhcp", + "import_executor": true, "integration_type": "system", "iot_class": "local_push", - "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], + "loggers": [ + "aiodiscover", + "aiodhcpwatcher", + "dnspython", + "pyroute2", + "scapy" + ], "quality_scale": "internal", "requirements": [ - "scapy==2.5.0", - "aiodiscover==1.6.0", + "aiodhcpwatcher==0.8.0", + "aiodiscover==1.6.1", "cached_ipaddress==0.3.0" ] } diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 939bd5f5000f45..679efd137ce2ec 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -85,7 +85,8 @@ async def async_get_device_diagnostics( """Return diagnostics for a device.""" -async def _register_diagnostics_platform( +@callback +def _register_diagnostics_platform( hass: HomeAssistant, integration_domain: str, platform: DiagnosticsProtocol ) -> None: """Register a diagnostics platform.""" diff --git a/homeassistant/components/diaz/__init__.py b/homeassistant/components/diaz/__init__.py new file mode 100644 index 00000000000000..9cf0c4708471d1 --- /dev/null +++ b/homeassistant/components/diaz/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Diaz.""" diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index b28c55b022fe3a..14c2cc6c04021a 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -36,11 +36,9 @@ async def async_step_reauth_confirm( if user_input: error, info = await _async_try_connect(user_input[CONF_API_TOKEN]) if info and (entry := await self.async_set_unique_id(str(info.id))): - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( entry, data=entry.data | user_input ) - await self.hass.config_entries.async_reload(entry.entry_id) - return self.async_abort(reason="reauth_successful") if error: errors["base"] = error diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 1b53ba83cee32e..78d4dc203e22cb 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -4,8 +4,9 @@ "codeowners": ["@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discord", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.0.0a8"] + "requirements": ["nextcord==2.6.0"] } diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 4330129049096e..ff83d97f8c21c7 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -129,10 +129,10 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: embeds: list[nextcord.Embed] = [] if ATTR_EMBED in data: embedding = data[ATTR_EMBED] - title = embedding.get(ATTR_EMBED_TITLE) or nextcord.Embed.Empty - description = embedding.get(ATTR_EMBED_DESCRIPTION) or nextcord.Embed.Empty - color = embedding.get(ATTR_EMBED_COLOR) or nextcord.Embed.Empty - url = embedding.get(ATTR_EMBED_URL) or nextcord.Embed.Empty + title = embedding.get(ATTR_EMBED_TITLE) + description = embedding.get(ATTR_EMBED_DESCRIPTION) + color = embedding.get(ATTR_EMBED_COLOR) + url = embedding.get(ATTR_EMBED_URL) fields = embedding.get(ATTR_EMBED_FIELDS) or [] if embedding: diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 5a3448a9e4bbae..e5e161a5d4031c 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -38,7 +38,9 @@ def __init__( async def _async_update_data(self) -> Reading: """Get last reading for meter.""" try: - return await self.discovergy_client.meter_last_reading(self.meter.meter_id) + return await self.discovergy_client.meter_last_reading( + meter_id=self.meter.meter_id + ) except InvalidLogin as err: raise ConfigEntryAuthFailed( f"Auth expired while fetching last reading for meter {self.meter.meter_id}" diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index f70a531215e3f2..da9fb117353358 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==2.0.5"] + "requirements": ["pydiscovergy==3.0.0"] } diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 00513db484b5d6..365a67fe552a9b 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -156,7 +156,7 @@ class DiscovergySensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - value_fn=lambda reading, key, scale: reading.time_with_timezone, + value_fn=lambda reading, key, scale: reading.time, ), ) @@ -212,7 +212,7 @@ def __init__( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", - model=meter.type, + model=meter.meter_type, manufacturer=MANUFACTURER, serial_number=meter.full_serial_number, ) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 749f2c887eb2fd..c8c704868545a6 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -19,6 +19,7 @@ from homeassistant.components import media_source, ssdp from homeassistant.components.media_player import ( ATTR_MEDIA_EXTRA, + DOMAIN as MEDIA_PLAYER_DOMAIN, BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -28,7 +29,7 @@ async_process_play_media_url, ) from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,6 +38,7 @@ CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, + DOMAIN, LOGGER as _LOGGER, MEDIA_METADATA_DIDL, MEDIA_TYPE_MAP, @@ -87,9 +89,32 @@ async def async_setup_entry( """Set up the DlnaDmrEntity from a config entry.""" _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title) + udn = entry.data[CONF_DEVICE_ID] + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + + if ( + ( + existing_entity_id := ent_reg.async_get_entity_id( + domain=MEDIA_PLAYER_DOMAIN, platform=DOMAIN, unique_id=udn + ) + ) + and (existing_entry := ent_reg.async_get(existing_entity_id)) + and (device_id := existing_entry.device_id) + and (device_entry := dev_reg.async_get(device_id)) + and (dr.CONNECTION_UPNP, udn) not in device_entry.connections + ): + # If the existing device is missing the udn connection, add it + # now to ensure that when the entity gets added it is linked to + # the correct device. + dev_reg.async_update_device( + device_id, + merge_connections={(dr.CONNECTION_UPNP, udn)}, + ) + # Create our own device-wrapping entity entity = DlnaDmrEntity( - udn=entry.data[CONF_DEVICE_ID], + udn=udn, device_type=entry.data[CONF_TYPE], name=entry.title, event_port=entry.options.get(CONF_LISTEN_PORT) or 0, @@ -98,6 +123,7 @@ async def async_setup_entry( location=entry.data[CONF_URL], mac_address=entry.data.get(CONF_MAC), browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False), + config_entry=entry, ) async_add_entities([entity]) @@ -143,6 +169,7 @@ def __init__( location: str, mac_address: str | None, browse_unfiltered: bool, + config_entry: config_entries.ConfigEntry, ) -> None: """Initialize DLNA DMR entity.""" self.udn = udn @@ -154,25 +181,17 @@ def __init__( self.mac_address = mac_address self.browse_unfiltered = browse_unfiltered self._device_lock = asyncio.Lock() + self._background_setup_task: asyncio.Task[None] | None = None + self._updated_registry: bool = False + self._config_entry = config_entry + self._attr_device_info = dr.DeviceInfo(connections={(dr.CONNECTION_UPNP, udn)}) async def async_added_to_hass(self) -> None: """Handle addition.""" # Update this entity when the associated config entry is modified - if self.registry_entry and self.registry_entry.config_entry_id: - config_entry = self.hass.config_entries.async_get_entry( - self.registry_entry.config_entry_id - ) - assert config_entry is not None - self.async_on_remove( - config_entry.add_update_listener(self.async_config_update_listener) - ) - - # Try to connect to the last known location, but don't worry if not available - if not self._device: - try: - await self._device_connect(self.location) - except UpnpError as err: - _LOGGER.debug("Couldn't connect immediately: %r", err) + self.async_on_remove( + self._config_entry.add_update_listener(self.async_config_update_listener) + ) # Get SSDP notifications for only this device self.async_on_remove( @@ -193,8 +212,29 @@ async def async_added_to_hass(self) -> None: ) ) + if not self._device: + if self.hass.state is CoreState.running: + await self._async_setup() + else: + self._background_setup_task = self.hass.async_create_background_task( + self._async_setup(), f"dlna_dmr {self.name} setup" + ) + + async def _async_setup(self) -> None: + # Try to connect to the last known location, but don't worry if not available + try: + await self._device_connect(self.location) + except UpnpError as err: + _LOGGER.debug("Couldn't connect immediately: %r", err) + async def async_will_remove_from_hass(self) -> None: """Handle removal.""" + if self._background_setup_task: + self._background_setup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._background_setup_task + self._background_setup_task = None + await self._device_disconnect() async def async_ssdp_callback( @@ -351,25 +391,28 @@ async def _device_connect(self, location: str) -> None: def _update_device_registry(self, set_mac: bool = False) -> None: """Update the device registry with new information about the DMR.""" - if not self._device: - return # Can't get all the required information without a connection - - if not self.registry_entry or not self.registry_entry.config_entry_id: - return # No config registry entry to link to - - if self.registry_entry.device_id and not set_mac: - return # No new information + if ( + # Can't get all the required information without a connection + not self._device + or + # No new information + (not set_mac and self._updated_registry) + ): + return - connections = set() # Connections based on the root device's UDN, and the DMR embedded # device's UDN. They may be the same, if the DMR is the root device. - connections.add( + connections = { ( dr.CONNECTION_UPNP, self._device.profile_device.root_device.udn, - ) - ) - connections.add((dr.CONNECTION_UPNP, self._device.udn)) + ), + (dr.CONNECTION_UPNP, self._device.udn), + ( + dr.CONNECTION_UPNP, + self.udn, + ), + } if self.mac_address: # Connection based on MAC address, if known @@ -378,23 +421,27 @@ def _update_device_registry(self, set_mac: bool = False) -> None: (dr.CONNECTION_NETWORK_MAC, self.mac_address) ) - # Create linked HA DeviceEntry now the information is known. - dev_reg = dr.async_get(self.hass) - device_entry = dev_reg.async_get_or_create( - config_entry_id=self.registry_entry.config_entry_id, + device_info = dr.DeviceInfo( connections=connections, default_manufacturer=self._device.manufacturer, default_model=self._device.model_name, default_name=self._device.name, ) + self._attr_device_info = device_info + + self._updated_registry = True + # Create linked HA DeviceEntry now the information is known. + device_entry = dr.async_get(self.hass).async_get_or_create( + config_entry_id=self._config_entry.entry_id, **device_info + ) # Update entity registry to link to the device - ent_reg = er.async_get(self.hass) - ent_reg.async_get_or_create( - self.registry_entry.domain, - self.registry_entry.platform, + er.async_get(self.hass).async_get_or_create( + MEDIA_PLAYER_DOMAIN, + DOMAIN, self.unique_id, device_id=device_entry.id, + config_entry=self._config_entry, ) async def _device_disconnect(self) -> None: @@ -419,6 +466,10 @@ async def _device_disconnect(self) -> None: async def async_update(self) -> None: """Retrieve the latest data.""" + if self._background_setup_task: + await self._background_setup_task + self._background_setup_task = None + if not self._device: if not self.poll_availability: return diff --git a/homeassistant/components/dnsip/icons.json b/homeassistant/components/dnsip/icons.json new file mode 100644 index 00000000000000..ea078158387607 --- /dev/null +++ b/homeassistant/components/dnsip/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "dnsip": { + "default": "mdi:web" + } + } + } +} diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index a4b0d34b339af7..975ec1992ae14e 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -52,8 +52,8 @@ async def async_setup_entry( class WanIpSensor(SensorEntity): """Implementation of a DNS IP sensor.""" - _attr_icon = "mdi:web" _attr_has_entity_name = True + _attr_translation_key = "dnsip" def __init__( self, diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index 1e1b4c55e18524..d77ac7a378efca 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -33,13 +33,13 @@ class DoorbirdButtonEntityDescription( RELAY_ENTITY_DESCRIPTION = DoorbirdButtonEntityDescription( key="relay", + translation_key="relay", press_action=lambda device, relay: device.energize_relay(relay), - icon="mdi:dip-switch", ) IR_ENTITY_DESCRIPTION = DoorbirdButtonEntityDescription( key="ir", + translation_key="ir", press_action=lambda device, _: device.turn_light_on(), - icon="mdi:lightbulb", ) diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index a4133f2da2cac8..3da47eb572acdc 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -108,7 +108,7 @@ async def async_camera_image( self._last_image = await response.read() self._last_update = now return self._last_image - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("DoorBird %s: Camera image timed out", self.name) return self._last_image except aiohttp.ClientError as error: diff --git a/homeassistant/components/doorbird/icons.json b/homeassistant/components/doorbird/icons.json new file mode 100644 index 00000000000000..7188080fafe9dc --- /dev/null +++ b/homeassistant/components/doorbird/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "button": { + "relay": { + "default": "mdi:dip-switch" + }, + "ir": { + "default": "mdi:lightbulb" + } + } + } +} diff --git a/homeassistant/components/dooya/__init__.py b/homeassistant/components/dooya/__init__.py new file mode 100644 index 00000000000000..9e8bf86ff220e8 --- /dev/null +++ b/homeassistant/components/dooya/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Dooya.""" diff --git a/homeassistant/components/dremel_3d_printer/icons.json b/homeassistant/components/dremel_3d_printer/icons.json new file mode 100644 index 00000000000000..ce48987df58ee1 --- /dev/null +++ b/homeassistant/components/dremel_3d_printer/icons.json @@ -0,0 +1,33 @@ +{ + "entity": { + "sensor": { + "job_phase": { + "default": "mdi:printer-3d" + }, + "progress": { + "default": "mdi:printer-3d-nozzle" + }, + "filament": { + "default": "mdi:printer-3d-nozzle" + }, + "job_status": { + "default": "mdi:printer-3d" + }, + "job_name": { + "default": "mdi:file" + }, + "api_version": { + "default": "mdi:api" + }, + "host": { + "default": "mdi:ip-network" + }, + "connection_type": { + "default": "mdi:network" + }, + "hours_used": { + "default": "mdi:clock" + } + } + } +} diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index b24b01d230856c..98e4cd0e85df7f 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -51,7 +51,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="job_phase", translation_key="job_phase", - icon="mdi:printer-3d", value_fn=lambda api, _: api.get_printing_status(), ), Dremel3DPrinterSensorEntityDescription( @@ -67,7 +66,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="progress", translation_key="progress", - icon="mdi:printer-3d-nozzle", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -162,7 +160,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="filament", translation_key="filament", - icon="mdi:printer-3d-nozzle", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_job_status()[key], @@ -190,7 +187,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="job_status", translation_key="job_status", - icon="mdi:printer-3d", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_job_status()[key], @@ -198,7 +194,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="job_name", translation_key="job_name", - icon="mdi:file", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, _: api.get_job_name(), @@ -206,7 +201,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="api_version", translation_key="api_version", - icon="mdi:api", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_printer_info()[key], @@ -214,7 +208,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="host", translation_key="host", - icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_printer_info()[key], @@ -222,7 +215,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="connection_type", translation_key="connection_type", - icon="mdi:network", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda api, key: api.get_printer_info()[key], @@ -239,7 +231,6 @@ class Dremel3DPrinterSensorEntityDescription( Dremel3DPrinterSensorEntityDescription( key="hours_used", translation_key="hours_used", - icon="mdi:clock", native_unit_of_measurement=UnitOfTime.HOURS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py index 1bce60f87b357e..73e0e254607945 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -31,11 +31,6 @@ _LOGGER = logging.getLogger(__name__) -LEAK_ICON = "mdi:pipe-leak" -NOTIFICATION_ICON = "mdi:bell-ring" -PUMP_ICON = "mdi:water-pump" -SALT_ICON = "mdi:shaker" -WATER_ICON = "mdi:water" # Binary sensor type constants LEAK_DETECTED = "leak" @@ -56,32 +51,27 @@ class DROPBinarySensorEntityDescription(BinarySensorEntityDescription): DROPBinarySensorEntityDescription( key=LEAK_DETECTED, translation_key=LEAK_DETECTED, - icon=LEAK_ICON, device_class=BinarySensorDeviceClass.MOISTURE, value_fn=lambda device: device.drop_api.leak_detected(), ), DROPBinarySensorEntityDescription( key=PENDING_NOTIFICATION, translation_key=PENDING_NOTIFICATION, - icon=NOTIFICATION_ICON, value_fn=lambda device: device.drop_api.notification_pending(), ), DROPBinarySensorEntityDescription( key=SALT_LOW, translation_key=SALT_LOW, - icon=SALT_ICON, value_fn=lambda device: device.drop_api.salt_low(), ), DROPBinarySensorEntityDescription( key=RESERVE_IN_USE, translation_key=RESERVE_IN_USE, - icon=WATER_ICON, value_fn=lambda device: device.drop_api.reserve_in_use(), ), DROPBinarySensorEntityDescription( key=PUMP_STATUS, translation_key=PUMP_STATUS, - icon=PUMP_ICON, value_fn=lambda device: device.drop_api.pump_status(), ), ] diff --git a/homeassistant/components/drop_connect/icons.json b/homeassistant/components/drop_connect/icons.json new file mode 100644 index 00000000000000..9392da79f0c259 --- /dev/null +++ b/homeassistant/components/drop_connect/icons.json @@ -0,0 +1,65 @@ +{ + "entity": { + "binary_sensor": { + "leak": { + "default": "mdi:pipe-leak" + }, + "pending_notification": { + "default": "mdi:bell-ring" + }, + "pump": { + "default": "mdi:water-pump" + }, + "reserve_in_use": { + "default": "mdi:water" + }, + "salt": { + "default": "mdi:shaker" + } + }, + "select": { + "protect_mode": { + "default": "mdi:home-flood" + } + }, + "sensor": { + "current_flow_rate": { + "default": "mdi:shower-head" + }, + "peak_flow_rate": { + "default": "mdi:shower-head" + }, + "inlet_tds": { + "default": "mdi:water-opacity" + }, + "outlet_tds": { + "default": "mdi:water-opacity" + }, + "cart1": { + "default": "mdi:gauge" + }, + "cart2": { + "default": "mdi:gauge" + }, + "cart3": { + "default": "mdi:gauge" + } + }, + "switch": { + "water": { + "default": "mdi:valve", + "state": { + "on": "mdi:valve-open", + "off": "mdi:valve-closed" + } + }, + "bypass": { + "default": "mdi:valve", + "state": { + "on": "mdi:valve-open", + "off": "mdi:valve-closed" + } + } + } + } +} diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py index e026cfcd59e73d..ad06576c9f315c 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -23,8 +23,6 @@ PROTECT_MODE_OPTIONS = ["away", "home", "schedule"] -FLOOD_ICON = "mdi:home-flood" - @dataclass(kw_only=True, frozen=True) class DROPSelectEntityDescription(SelectEntityDescription): @@ -38,7 +36,6 @@ class DROPSelectEntityDescription(SelectEntityDescription): DROPSelectEntityDescription( key=PROTECT_MODE, translation_key=PROTECT_MODE, - icon=FLOOD_ICON, options=PROTECT_MODE_OPTIONS, value_fn=lambda device: device.drop_api.protect_mode(), set_fn=lambda device, value: device.set_protect_mode(value), diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index c5215df8395ff3..c9450440473d70 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -39,9 +39,6 @@ _LOGGER = logging.getLogger(__name__) -FLOW_ICON = "mdi:shower-head" -GAUGE_ICON = "mdi:gauge" -TDS_ICON = "mdi:water-opacity" # Sensor type constants CURRENT_FLOW_RATE = "current_flow_rate" @@ -72,7 +69,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=CURRENT_FLOW_RATE, translation_key=CURRENT_FLOW_RATE, - icon="mdi:shower-head", native_unit_of_measurement="gpm", suggested_display_precision=1, value_fn=lambda device: device.drop_api.current_flow_rate(), @@ -81,7 +77,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=PEAK_FLOW_RATE, translation_key=PEAK_FLOW_RATE, - icon="mdi:shower-head", native_unit_of_measurement="gpm", suggested_display_precision=1, value_fn=lambda device: device.drop_api.peak_flow_rate(), @@ -161,7 +156,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=INLET_TDS, translation_key=INLET_TDS, - icon=TDS_ICON, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -170,7 +164,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=OUTLET_TDS, translation_key=OUTLET_TDS, - icon=TDS_ICON, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -179,7 +172,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=CARTRIDGE_1_LIFE, translation_key=CARTRIDGE_1_LIFE, - icon=GAUGE_ICON, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -189,7 +181,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=CARTRIDGE_2_LIFE, translation_key=CARTRIDGE_2_LIFE, - icon=GAUGE_ICON, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -199,7 +190,6 @@ class DROPSensorEntityDescription(SensorEntityDescription): DROPSensorEntityDescription( key=CARTRIDGE_3_LIFE, translation_key=CARTRIDGE_3_LIFE, - icon=GAUGE_ICON, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py index b0ebe4b5a8592e..98841d7ca24e77 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -25,11 +25,6 @@ _LOGGER = logging.getLogger(__name__) -ICON_VALVE_OPEN = "mdi:valve-open" -ICON_VALVE_CLOSED = "mdi:valve-closed" -ICON_VALVE_UNKNOWN = "mdi:valve" -ICON_VALVE = {False: ICON_VALVE_CLOSED, True: ICON_VALVE_OPEN, None: ICON_VALVE_UNKNOWN} - SWITCH_VALUE: dict[int | None, bool] = {0: False, 1: True} # Switch type constants @@ -49,14 +44,12 @@ class DROPSwitchEntityDescription(SwitchEntityDescription): DROPSwitchEntityDescription( key=WATER_SWITCH, translation_key=WATER_SWITCH, - icon=ICON_VALVE_UNKNOWN, value_fn=lambda device: device.drop_api.water(), set_fn=lambda device, value: device.set_water(value), ), DROPSwitchEntityDescription( key=BYPASS_SWITCH, translation_key=BYPASS_SWITCH, - icon=ICON_VALVE_UNKNOWN, value_fn=lambda device: device.drop_api.bypass(), set_fn=lambda device, value: device.set_bypass(value), ), @@ -117,8 +110,3 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn switch off.""" await self.entity_description.set_fn(self.coordinator, 0) - - @property - def icon(self) -> str: - """Return the icon to use for dynamic states.""" - return ICON_VALVE[self.is_on] diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 376b4d100fc97c..a38326c13461f1 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -123,7 +123,7 @@ def update_telegram(telegram: dict[str, DSMRObject]) -> None: try: async with asyncio.timeout(30): await protocol.wait_closed() - except asyncio.TimeoutError: + except TimeoutError: # Timeout (no data received), close transport and return True (if telegram is empty, will result in CannotCommunicate error) transport.close() await protocol.wait_closed() diff --git a/homeassistant/components/dsmr/icons.json b/homeassistant/components/dsmr/icons.json new file mode 100644 index 00000000000000..39a39a47e39d64 --- /dev/null +++ b/homeassistant/components/dsmr/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "electricity_active_tariff": { + "default": "mdi:flash" + }, + "short_power_failure_count": { + "default": "mdi:flash-off" + }, + "long_power_failure_count": { + "default": "mdi:flash-off" + }, + "voltage_swell_l1_count": { + "default": "mdi:pulse" + }, + "voltage_swell_l2_count": { + "default": "mdi:pulse" + }, + "voltage_swell_l3_count": { + "default": "mdi:pulse" + } + } + } +} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 79136a27f16243..ad1c4e64c5530b 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -106,7 +106,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENUM, options=["low", "normal"], - icon="mdi:flash", ), DSMRSensorEntityDescription( key="electricity_used_tariff_1", @@ -194,7 +193,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, - icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -203,7 +201,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, - icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -236,7 +233,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, - icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -245,7 +241,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, - icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -254,7 +249,6 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, - icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -353,6 +347,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key="belgium_maximum_demand_current_month", @@ -360,6 +355,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index 4f6bf6fb67777c..ff7f78d537bb04 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -5,10 +5,14 @@ from pdunehd import DuneHDPlayer +from homeassistant.components import media_source from homeassistant.components.media_player import ( + BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, + async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -26,6 +30,8 @@ | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA ) @@ -115,7 +121,7 @@ def turn_off(self) -> None: self._state = self._player.turn_off() def turn_on(self) -> None: - """Turn off media player.""" + """Turn on media player.""" self._state = self._player.turn_on() def media_play(self) -> None: @@ -126,6 +132,32 @@ def media_pause(self) -> None: """Pause media player.""" self._state = self._player.pause() + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play media from a URL or file.""" + # Handle media_source + if media_source.is_media_source_id(media_id): + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = sourced_media.url + + # If media ID is a relative URL, we serve it from HA. + media_id = async_process_play_media_url(self.hass, media_id) + + self._state = await self.hass.async_add_executor_job( + self._player.launch_media_url, media_id + ) + + async def async_browse_media( + 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) + @property def media_title(self) -> str | None: """Return the current media source.""" diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py index d9d890c28f3060..288210c72807ae 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -12,11 +12,11 @@ from .const import DOMAIN PLATFORMS: list[Platform] = [ - Platform.SWITCH, + Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.COVER, Platform.LIGHT, - Platform.CLIMATE, - Platform.BINARY_SENSOR, + Platform.SWITCH, ] diff --git a/homeassistant/components/duquesne_light/__init__.py b/homeassistant/components/duquesne_light/__init__.py new file mode 100644 index 00000000000000..33c35ecb4cd5e7 --- /dev/null +++ b/homeassistant/components/duquesne_light/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Duquesne Light.""" diff --git a/homeassistant/components/duquesne_light/manifest.json b/homeassistant/components/duquesne_light/manifest.json new file mode 100644 index 00000000000000..3cb01757950a15 --- /dev/null +++ b/homeassistant/components/duquesne_light/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "duquesne_light", + "name": "Duquesne Light", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/dynalite/icons.json b/homeassistant/components/dynalite/icons.json new file mode 100644 index 00000000000000..dedbb1be3acc71 --- /dev/null +++ b/homeassistant/components/dynalite/icons.json @@ -0,0 +1,6 @@ +{ + "services": { + "request_area_preset": "mdi:texture-box", + "request_channel_level": "mdi:satellite-uplink" + } +} diff --git a/homeassistant/components/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index a57558ff1cc7a7..d18553b6ee61e1 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -1,16 +1,58 @@ """UK Environment Agency Flood Monitoring Integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from aioeafm import get_station + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +def get_measures(station_data): + """Force measure key to always be a list.""" + if "measures" not in station_data: + return [] + if isinstance(station_data["measures"], dict): + return [station_data["measures"]] + return station_data["measures"] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up flood monitoring sensors for this config entry.""" - hass.data.setdefault(DOMAIN, {}) + station_key = entry.data["station"] + session = async_get_clientsession(hass=hass) + + async def _async_update_data() -> dict[str, dict[str, Any]]: + # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts + async with asyncio.timeout(30): + data = await get_station(session, station_key) + + measures = get_measures(data) + # Turn data.measures into a dict rather than a list so easier for entities to + # find themselves. + data["measures"] = {measure["@id"]: measure for measure in measures} + return data + + coordinator = DataUpdateCoordinator[dict[str, dict[str, Any]]]( + hass, + _LOGGER, + name="sensor", + update_method=_async_update_data, + update_interval=timedelta(seconds=15 * 60), + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py index 2c7f8456a72b32..297f4d6d2c8e57 100644 --- a/homeassistant/components/eafm/sensor.py +++ b/homeassistant/components/eafm/sensor.py @@ -1,15 +1,11 @@ """Support for gauges from flood monitoring API.""" -import asyncio -from datetime import timedelta -import logging -from aioeafm import get_station +from typing import Any from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( @@ -19,72 +15,49 @@ from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - UNIT_MAPPING = { "http://qudt.org/1.1/vocab/unit#Meter": UnitOfLength.METERS, } -def get_measures(station_data): - """Force measure key to always be a list.""" - if "measures" not in station_data: - return [] - if isinstance(station_data["measures"], dict): - return [station_data["measures"]] - return station_data["measures"] - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up UK Flood Monitoring Sensors.""" - station_key = config_entry.data["station"] - session = async_get_clientsession(hass=hass) - - measurements = set() - - async def async_update_data(): - # DataUpdateCoordinator will handle aiohttp ClientErrors and timeouts - async with asyncio.timeout(30): - data = await get_station(session, station_key) - - measures = get_measures(data) - entities = [] - + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + created_entities: set[str] = set() + + @callback + def _async_create_new_entities(): + """Create new entities.""" + if not coordinator.last_update_success: + return + measures: dict[str, dict[str, Any]] = coordinator.data["measures"] + entities: list[Measurement] = [] # Look to see if payload contains new measures - for measure in measures: - if measure["@id"] in measurements: + for key, data in measures.items(): + if key in created_entities: continue - if "latestReading" not in measure: + if "latestReading" not in data: # Don't create a sensor entity for a gauge that isn't available continue - entities.append(Measurement(hass.data[DOMAIN][station_key], measure["@id"])) - measurements.add(measure["@id"]) + entities.append(Measurement(coordinator, key)) + created_entities.add(key) async_add_entities(entities) - # Turn data.measures into a dict rather than a list so easier for entities to - # find themselves. - data["measures"] = {measure["@id"]: measure for measure in measures} + _async_create_new_entities() - return data - - hass.data[DOMAIN][station_key] = coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="sensor", - update_method=async_update_data, - update_interval=timedelta(seconds=15 * 60), + # Subscribe to the coordinator to create new entities + # when the coordinator updates + config_entry.async_on_unload( + coordinator.async_add_listener(_async_create_new_entities) ) - # Fetch initial data so we have data when entities subscribe - await coordinator.async_refresh() - class Measurement(CoordinatorEntity, SensorEntity): """A gauge at a flood monitoring station.""" diff --git a/homeassistant/components/easyenergy/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index 6bc5ed3803abf4..0c8851748729b3 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -21,6 +21,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: Returns: The gas market price value. + """ if not data.gas_today: return None diff --git a/homeassistant/components/easyenergy/icons.json b/homeassistant/components/easyenergy/icons.json new file mode 100644 index 00000000000000..90cbec17a65830 --- /dev/null +++ b/homeassistant/components/easyenergy/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "sensor": { + "percentage_of_max": { + "default": "mdi:percent" + }, + "hours_priced_equal_or_lower": { + "default": "mdi:clock" + }, + "hours_priced_equal_or_higher": { + "default": "mdi:clock" + } + } + }, + "services": { + "get_gas_prices": "mdi:gas-station", + "get_energy_usage_prices": "mdi:transmission-tower-import", + "get_energy_return_prices": "mdi:transmission-tower-export" + } +} diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 7298c49660f613..d719eac17aff1c 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -117,7 +117,6 @@ class EasyEnergySensorEntityDescription( translation_key="percentage_of_max", service_type="today_energy_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_usage, ), EasyEnergySensorEntityDescription( @@ -177,7 +176,6 @@ class EasyEnergySensorEntityDescription( translation_key="percentage_of_max", service_type="today_energy_return", native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_return, ), EasyEnergySensorEntityDescription( @@ -185,7 +183,6 @@ class EasyEnergySensorEntityDescription( translation_key="hours_priced_equal_or_lower", service_type="today_energy_usage", native_unit_of_measurement=UnitOfTime.HOURS, - icon="mdi:clock", value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage, ), EasyEnergySensorEntityDescription( @@ -193,7 +190,6 @@ class EasyEnergySensorEntityDescription( translation_key="hours_priced_equal_or_higher", service_type="today_energy_return", native_unit_of_measurement=UnitOfTime.HOURS, - icon="mdi:clock", value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return, ), ) @@ -208,6 +204,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None: Returns: The gas market price value. + """ if data.gas_today is None: return None diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py index ab83759bb2df8b..b1eb03989eaa8d 100644 --- a/homeassistant/components/ebusd/__init__.py +++ b/homeassistant/components/ebusd/__init__.py @@ -1,6 +1,5 @@ """Support for Ebusd daemon for communication with eBUS heating systems.""" import logging -import socket import ebusdpy import voluptuous as vol @@ -80,7 +79,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.debug("Ebusd integration setup completed") return True - except (socket.timeout, OSError): + except (TimeoutError, OSError): return False diff --git a/homeassistant/components/ecobee/icons.json b/homeassistant/components/ecobee/icons.json new file mode 100644 index 00000000000000..3e736d0dc68700 --- /dev/null +++ b/homeassistant/components/ecobee/icons.json @@ -0,0 +1,11 @@ +{ + "services": { + "create_vacation": "mdi:umbrella-beach", + "delete_vacation": "mdi:umbrella-beach-outline", + "resume_program": "mdi:play", + "set_fan_min_on_time": "mdi:fan-clock", + "set_dst_mode": "mdi:sun-clock", + "set_mic_mode": "mdi:microphone", + "set_occupancy_modes": "mdi:eye-settings" + } +} diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py index 205dfe67deb6bf..7b4dd08610a860 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -18,7 +18,7 @@ from .const import DOMAIN from .coordinator import EcoforestCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.NUMBER, Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ecoforest/icons.json b/homeassistant/components/ecoforest/icons.json new file mode 100644 index 00000000000000..4cd93399184dd0 --- /dev/null +++ b/homeassistant/components/ecoforest/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "alarm": { + "default": "mdi:alert" + } + } + } +} diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index 6f903bee2babb5..90904d274ac300 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -90,7 +90,6 @@ class EcoforestSensorEntityDescription( translation_key="alarm", device_class=SensorDeviceClass.ENUM, options=ALARM_TYPE, - icon="mdi:alert", value_fn=lambda data: data.alarm.value if data.alarm else "none", ), EcoforestSensorEntityDescription( diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index ce7222f96a2ac0..945f999cf79197 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -28,6 +28,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.IMAGE, + Platform.LAWN_MOWER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 95e87a04b18d4a..f04f2110003aa1 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import CapabilityEvent +from deebot_client.capabilities import CapabilityEvent, VacuumCapabilities from deebot_client.events.water_info import WaterInfoEvent from homeassistant.components.binary_sensor import ( @@ -17,7 +17,12 @@ from .const import DOMAIN from .controller import EcovacsController -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import ( + CapabilityDevice, + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EventT, +) from .util import get_supported_entitites @@ -34,6 +39,7 @@ class EcovacsBinarySensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( EcovacsBinarySensorEntityDescription[WaterInfoEvent]( + device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.water, value_fn=lambda e: e.mop_attached, key="water_mop_attached", @@ -56,7 +62,7 @@ async def async_setup_entry( class EcovacsBinarySensor( - EcovacsDescriptionEntity[CapabilityEvent[EventT]], + EcovacsDescriptionEntity[CapabilityDevice, CapabilityEvent[EventT]], BinarySensorEntity, ): """Ecovacs binary sensor.""" diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index c2e5458c2ed727..0e0117260103c5 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -1,7 +1,12 @@ """Ecovacs button module.""" from dataclasses import dataclass -from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan +from deebot_client.capabilities import ( + Capabilities, + CapabilityExecute, + CapabilityLifeSpan, + VacuumCapabilities, +) from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -13,6 +18,7 @@ from .const import DOMAIN, SUPPORTED_LIFESPANS from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -37,6 +43,7 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription): ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = ( EcovacsButtonEntityDescription( + device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.map.relocation if caps.map else None, key="relocate", translation_key="relocate", @@ -66,7 +73,7 @@ async def async_setup_entry( entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS ) - for device in controller.devices: + for device in controller.devices(Capabilities): lifespan_capability = device.capabilities.life_span for description in LIFESPAN_ENTITY_DESCRIPTIONS: if description.component in lifespan_capability.types: @@ -81,7 +88,7 @@ async def async_setup_entry( class EcovacsButtonEntity( - EcovacsDescriptionEntity[CapabilityExecute], + EcovacsDescriptionEntity[CapabilityDevice, CapabilityExecute], ButtonEntity, ): """Ecovacs button entity.""" @@ -94,7 +101,7 @@ async def async_press(self) -> None: class EcovacsResetLifespanButtonEntity( - EcovacsDescriptionEntity[CapabilityLifeSpan], + EcovacsDescriptionEntity[Capabilities, CapabilityLifeSpan], ButtonEntity, ): """Ecovacs reset lifespan button entity.""" diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 27b64db20b621a..6ba5dcdba6c02a 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -1,13 +1,14 @@ """Controller module.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Generator, Mapping import logging import ssl from typing import Any from deebot_client.api_client import ApiClient from deebot_client.authentication import Authenticator, create_rest_config +from deebot_client.capabilities import Capabilities from deebot_client.const import UNDEFINED, UndefinedType from deebot_client.device import Device from deebot_client.exceptions import DeebotError, InvalidAuthenticationError @@ -18,7 +19,7 @@ from sucks import EcoVacsAPI, VacBot from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.util.ssl import get_default_no_verify_context @@ -39,7 +40,7 @@ class EcovacsController: def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: """Initialize controller.""" self._hass = hass - self.devices: list[Device] = [] + self._devices: list[Device] = [] self.legacy_devices: list[VacBot] = [] self._device_id = get_client_device_id() country = config[CONF_COUNTRY] @@ -86,7 +87,7 @@ async def initialize(self) -> None: mqtt_config_verfied = True device = Device(device_config, self._authenticator) await device.initialize(self._mqtt) - self.devices.append(device) + self._devices.append(device) else: # Legacy device bot = VacBot( @@ -108,9 +109,16 @@ async def initialize(self) -> None: async def teardown(self) -> None: """Disconnect controller.""" - for device in self.devices: + for device in self._devices: await device.teardown() for legacy_device in self.legacy_devices: await self._hass.async_add_executor_job(legacy_device.disconnect) await self._mqtt.disconnect() await self._authenticator.teardown() + + @callback + def devices(self, capability: type[Capabilities]) -> Generator[Device, None, None]: + """Return generator for devices with a specific capability.""" + for device in self._devices: + if isinstance(device.capabilities, capability): + yield device diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py index d961e231631c87..6493dce2712d4a 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -3,6 +3,8 @@ from typing import Any +from deebot_client.capabilities import Capabilities + from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME @@ -31,8 +33,8 @@ async def async_get_config_entry_diagnostics( } diag["devices"] = [ - async_redact_data(device.device_info.api_device_info, REDACT_DEVICE) - for device in controller.devices + async_redact_data(device.device_info, REDACT_DEVICE) + for device in controller.devices(Capabilities) ] diag["legacy_devices"] = [ async_redact_data(device.vacuum, REDACT_DEVICE) diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 20de6914700c41..817172016bc8c5 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -16,11 +16,12 @@ from .const import DOMAIN -CapabilityT = TypeVar("CapabilityT") +CapabilityEntity = TypeVar("CapabilityEntity") +CapabilityDevice = TypeVar("CapabilityDevice", bound=Capabilities) EventT = TypeVar("EventT", bound=Event) -class EcovacsEntity(Entity, Generic[CapabilityT]): +class EcovacsEntity(Entity, Generic[CapabilityDevice, CapabilityEntity]): """Ecovacs entity.""" _attr_should_poll = False @@ -29,13 +30,15 @@ class EcovacsEntity(Entity, Generic[CapabilityT]): def __init__( self, - device: Device, - capability: CapabilityT, + device: Device[CapabilityDevice], + capability: CapabilityEntity, **kwargs: Any, ) -> None: """Initialize entity.""" super().__init__(**kwargs) - self._attr_unique_id = f"{device.device_info.did}_{self.entity_description.key}" + self._attr_unique_id = ( + f"{device.device_info['did']}_{self.entity_description.key}" + ) self._device = device self._capability = capability @@ -46,16 +49,16 @@ def device_info(self) -> DeviceInfo | None: """Return device specific attributes.""" device_info = self._device.device_info info = DeviceInfo( - identifiers={(DOMAIN, device_info.did)}, + identifiers={(DOMAIN, device_info["did"])}, manufacturer="Ecovacs", sw_version=self._device.fw_version, - serial_number=device_info.name, + serial_number=device_info["name"], ) - if nick := device_info.api_device_info.get("nick"): + if nick := device_info.get("nick"): info["name"] = nick - if model := device_info.api_device_info.get("deviceName"): + if model := device_info.get("deviceName"): info["model"] = model if mac := self._device.mac: @@ -93,13 +96,13 @@ async def async_update(self) -> None: self._device.events.request_refresh(event_type) -class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT]): +class EcovacsDescriptionEntity(EcovacsEntity[CapabilityDevice, CapabilityEntity]): """Ecovacs entity.""" def __init__( self, - device: Device, - capability: CapabilityT, + device: Device[CapabilityDevice], + capability: CapabilityEntity, entity_description: EntityDescription, **kwargs: Any, ) -> None: @@ -111,8 +114,9 @@ def __init__( @dataclass(kw_only=True, frozen=True) class EcovacsCapabilityEntityDescription( EntityDescription, - Generic[CapabilityT], + Generic[CapabilityDevice, CapabilityEntity], ): """Ecovacs entity description.""" - capability_fn: Callable[[Capabilities], CapabilityT | None] + device_capabilities: type[CapabilityDevice] + capability_fn: Callable[[CapabilityDevice], CapabilityEntity | None] diff --git a/homeassistant/components/ecovacs/icons.json b/homeassistant/components/ecovacs/icons.json index b639ff81e63f6c..7a57259ca5adae 100644 --- a/homeassistant/components/ecovacs/icons.json +++ b/homeassistant/components/ecovacs/icons.json @@ -83,15 +83,30 @@ "advanced_mode": { "default": "mdi:tune" }, + "border_switch": { + "default": "mdi:land-fields" + }, "carpet_auto_fan_boost": { "default": "mdi:fan-auto" }, + "child_lock": { + "default": "mdi:teddy-bear" + }, "clean_preference": { "default": "mdi:broom" }, + "cross_map_border_warning": { + "default": "mdi:border-none-variant" + }, "continuous_cleaning": { "default": "mdi:refresh-auto" }, + "move_up_warning": { + "default": "mdi:arrow-up-bold-box-outline" + }, + "safe_protect": { + "default": "mdi:shield-half-full" + }, "true_detect": { "default": "mdi:laser-pointer" } diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index 18c162138fb27e..82e20e19732a57 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -1,6 +1,6 @@ """Ecovacs image entities.""" -from deebot_client.capabilities import CapabilityMap +from deebot_client.capabilities import CapabilityMap, VacuumCapabilities from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent @@ -23,8 +23,9 @@ async def async_setup_entry( """Add entities for passed config_entry in HA.""" controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] entities = [] - for device in controller.devices: - if caps := device.capabilities.map: + for device in controller.devices(VacuumCapabilities): + capabilities: VacuumCapabilities = device.capabilities + if caps := capabilities.map: entities.append(EcovacsMap(device, caps, hass)) if entities: @@ -32,7 +33,7 @@ async def async_setup_entry( class EcovacsMap( - EcovacsEntity[CapabilityMap], + EcovacsEntity[VacuumCapabilities, CapabilityMap], ImageEntity, ): """Ecovacs map.""" @@ -72,7 +73,7 @@ async def on_changed(event: MapChangedEvent) -> None: self._attr_image_last_updated = event.when self.async_write_ha_state() - self._subscribe(self._capability.chached_info.event, on_info) + self._subscribe(self._capability.cached_info.event, on_info) self._subscribe(self._capability.changed.event, on_changed) async def async_update(self) -> None: diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py new file mode 100644 index 00000000000000..e33e87bc5fbceb --- /dev/null +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -0,0 +1,99 @@ +"""Ecovacs mower entity.""" + +from __future__ import annotations + +import logging + +from deebot_client.capabilities import MowerCapabilities +from deebot_client.device import Device +from deebot_client.events import StateEvent +from deebot_client.models import CleanAction, State + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityEntityDescription, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import EcovacsController +from .entity import EcovacsEntity + +_LOGGER = logging.getLogger(__name__) + + +_STATE_TO_MOWER_STATE = { + State.IDLE: LawnMowerActivity.PAUSED, + State.CLEANING: LawnMowerActivity.MOWING, + State.RETURNING: LawnMowerActivity.MOWING, + State.DOCKED: LawnMowerActivity.DOCKED, + State.ERROR: LawnMowerActivity.ERROR, + State.PAUSED: LawnMowerActivity.PAUSED, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Ecovacs mowers.""" + mowers: list[EcovacsMower] = [] + controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + for device in controller.devices(MowerCapabilities): + mowers.append(EcovacsMower(device)) + _LOGGER.debug("Adding Ecovacs Mowers to Home Assistant: %s", mowers) + async_add_entities(mowers) + + +class EcovacsMower( + EcovacsEntity[MowerCapabilities, MowerCapabilities], + LawnMowerEntity, +): + """Ecovacs Mower.""" + + _attr_supported_features = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING + ) + + entity_description = LawnMowerEntityEntityDescription( + key="mower", translation_key="mower", name=None + ) + + def __init__(self, device: Device[MowerCapabilities]) -> None: + """Initialize the mower.""" + capabilities = device.capabilities + super().__init__(device, capabilities) + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + async def on_status(event: StateEvent) -> None: + self._attr_activity = _STATE_TO_MOWER_STATE[event.state] + self.async_write_ha_state() + + self._subscribe(self._capability.state.event, on_status) + + async def _clean_command(self, action: CleanAction) -> None: + await self._device.execute_command( + self._capability.clean.action.command(action) + ) + + async def async_start_mowing(self) -> None: + """Resume schedule.""" + await self._clean_command(CleanAction.START) + + async def async_pause(self) -> None: + """Pauses the mower.""" + await self._clean_command(CleanAction.PAUSE) + + async def async_dock(self) -> None: + """Parks the mower until next schedule.""" + await self._device.execute_command(self._capability.charge.execute()) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 326c2916bedf18..52753e6eb391c7 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -1,10 +1,10 @@ { "domain": "ecovacs", "name": "Ecovacs", - "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus"], + "codeowners": ["@OverloadUT", "@mib1185", "@edenhaus", "@Augar"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.9", "deebot-client==5.2.2"] + "requirements": ["py-sucks==0.9.9", "deebot-client==6.0.2"] } diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 45250ab69b1551..0dc379c68f09f2 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import CapabilitySet +from deebot_client.capabilities import Capabilities, CapabilitySet, VacuumCapabilities from deebot_client.events import CleanCountEvent, VolumeEvent from homeassistant.components.number import NumberEntity, NumberEntityDescription @@ -17,6 +17,7 @@ from .const import DOMAIN from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -39,6 +40,7 @@ class EcovacsNumberEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( EcovacsNumberEntityDescription[VolumeEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.settings.volume, value_fn=lambda e: e.volume, native_max_value_fn=lambda e: e.maximum, @@ -51,6 +53,7 @@ class EcovacsNumberEntityDescription( native_step=1.0, ), EcovacsNumberEntityDescription[CleanCountEvent]( + device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.clean.count, value_fn=lambda e: e.count, key="clean_count", @@ -79,7 +82,7 @@ async def async_setup_entry( class EcovacsNumberEntity( - EcovacsDescriptionEntity[CapabilitySet[EventT, int]], + EcovacsDescriptionEntity[CapabilityDevice, CapabilitySet[EventT, int]], NumberEntity, ): """Ecovacs number entity.""" diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index cd1cdd10379ce6..00e7134266bb5d 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Generic -from deebot_client.capabilities import CapabilitySetTypes +from deebot_client.capabilities import CapabilitySetTypes, VacuumCapabilities from deebot_client.device import Device from deebot_client.events import WaterInfoEvent, WorkModeEvent @@ -15,7 +15,12 @@ from .const import DOMAIN from .controller import EcovacsController -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import ( + CapabilityDevice, + EcovacsCapabilityEntityDescription, + EcovacsDescriptionEntity, + EventT, +) from .util import get_supported_entitites @@ -33,6 +38,7 @@ class EcovacsSelectEntityDescription( ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( EcovacsSelectEntityDescription[WaterInfoEvent]( + device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.water, current_option_fn=lambda e: e.amount.display_name, options_fn=lambda water: [amount.display_name for amount in water.types], @@ -41,6 +47,7 @@ class EcovacsSelectEntityDescription( entity_category=EntityCategory.CONFIG, ), EcovacsSelectEntityDescription[WorkModeEvent]( + device_capabilities=VacuumCapabilities, capability_fn=lambda caps: caps.clean.work_mode, current_option_fn=lambda e: e.mode.display_name, options_fn=lambda cap: [mode.display_name for mode in cap.types], @@ -67,7 +74,7 @@ async def async_setup_entry( class EcovacsSelectEntity( - EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]], + EcovacsDescriptionEntity[CapabilityDevice, CapabilitySetTypes[EventT, str]], SelectEntity, ): """Ecovacs select entity.""" diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 16a1b4acd43b65..6efc9ec0385dda 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Generic -from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan +from deebot_client.capabilities import Capabilities, CapabilityEvent, CapabilityLifeSpan from deebot_client.events import ( BatteryEvent, ErrorEvent, @@ -39,6 +39,7 @@ from .const import DOMAIN, SUPPORTED_LIFESPANS from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -62,6 +63,7 @@ class EcovacsSensorEntityDescription( # Stats EcovacsSensorEntityDescription[StatsEvent]( key="stats_area", + device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.area, translation_key="stats_area", @@ -69,6 +71,7 @@ class EcovacsSensorEntityDescription( ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", + device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.time, translation_key="stats_time", @@ -78,6 +81,7 @@ class EcovacsSensorEntityDescription( ), # TotalStats EcovacsSensorEntityDescription[TotalStatsEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.area, key="total_stats_area", @@ -86,6 +90,7 @@ class EcovacsSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.time, key="total_stats_time", @@ -96,6 +101,7 @@ class EcovacsSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.stats.total, value_fn=lambda e: e.cleanings, key="total_stats_cleanings", @@ -103,6 +109,7 @@ class EcovacsSensorEntityDescription( state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[BatteryEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.battery, value_fn=lambda e: e.value, key=ATTR_BATTERY_LEVEL, @@ -111,6 +118,7 @@ class EcovacsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.ip, key="network_ip", @@ -119,6 +127,7 @@ class EcovacsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.rssi, key="network_rssi", @@ -127,6 +136,7 @@ class EcovacsSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, ), EcovacsSensorEntityDescription[NetworkInfoEvent]( + device_capabilities=Capabilities, capability_fn=lambda caps: caps.network, value_fn=lambda e: e.ssid, key="network_ssid", @@ -169,7 +179,7 @@ async def async_setup_entry( entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsSensor, ENTITY_DESCRIPTIONS ) - for device in controller.devices: + for device in controller.devices(Capabilities): lifespan_capability = device.capabilities.life_span for description in LIFESPAN_ENTITY_DESCRIPTIONS: if description.component in lifespan_capability.types: @@ -184,7 +194,7 @@ async def async_setup_entry( class EcovacsSensor( - EcovacsDescriptionEntity[CapabilityEvent], + EcovacsDescriptionEntity[CapabilityDevice, CapabilityEvent], SensorEntity, ): """Ecovacs sensor.""" @@ -207,7 +217,7 @@ async def on_event(event: Event) -> None: class EcovacsLifespanSensor( - EcovacsDescriptionEntity[CapabilityLifeSpan], + EcovacsDescriptionEntity[Capabilities, CapabilityLifeSpan], SensorEntity, ): """Lifespan sensor.""" @@ -227,7 +237,7 @@ async def on_event(event: LifeSpanEvent) -> None: class EcovacsErrorSensor( - EcovacsEntity[CapabilityEvent[ErrorEvent]], + EcovacsEntity[Capabilities, CapabilityEvent[ErrorEvent]], SensorEntity, ): """Error sensor.""" diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 7a456483877658..1f43b830778246 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -136,15 +136,30 @@ "advanced_mode": { "name": "Advanced mode" }, + "border_switch": { + "name": "Border switch" + }, "carpet_auto_fan_boost": { "name": "Carpet auto-boost suction" }, + "child_lock": { + "name": "Child lock" + }, "clean_preference": { "name": "Clean preference" }, + "cross_map_border_warning": { + "name": "Cross map border warning" + }, "continuous_cleaning": { "name": "Continuous cleaning" }, + "move_up_warning": { + "name": "Move up warning" + }, + "safe_protect": { + "name": "Safe protect" + }, "true_detect": { "name": "True detect" } diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index e9e915877d87cc..316ed5427bafd2 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -2,7 +2,11 @@ from dataclasses import dataclass from typing import Any -from deebot_client.capabilities import CapabilitySetEnable +from deebot_client.capabilities import ( + Capabilities, + CapabilitySetEnable, + VacuumCapabilities, +) from deebot_client.events import EnableEvent from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -14,6 +18,7 @@ from .const import DOMAIN from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -24,47 +29,92 @@ @dataclass(kw_only=True, frozen=True) class EcovacsSwitchEntityDescription( SwitchEntityDescription, - EcovacsCapabilityEntityDescription, + EcovacsCapabilityEntityDescription[CapabilityDevice, CapabilitySetEnable], ): """Ecovacs switch entity description.""" ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( - EcovacsSwitchEntityDescription( + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, capability_fn=lambda c: c.settings.advanced_mode, key="advanced_mode", translation_key="advanced_mode", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription( + EcovacsSwitchEntityDescription[VacuumCapabilities]( + device_capabilities=VacuumCapabilities, capability_fn=lambda c: c.clean.continuous, key="continuous_cleaning", translation_key="continuous_cleaning", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription( + EcovacsSwitchEntityDescription[VacuumCapabilities]( + device_capabilities=VacuumCapabilities, capability_fn=lambda c: c.settings.carpet_auto_fan_boost, key="carpet_auto_fan_boost", translation_key="carpet_auto_fan_boost", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription( + EcovacsSwitchEntityDescription[VacuumCapabilities]( + device_capabilities=VacuumCapabilities, capability_fn=lambda c: c.clean.preference, key="clean_preference", translation_key="clean_preference", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), - EcovacsSwitchEntityDescription( + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, capability_fn=lambda c: c.settings.true_detect, key="true_detect", translation_key="true_detect", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, + capability_fn=lambda c: c.settings.border_switch, + key="border_switch", + translation_key="border_switch", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, + capability_fn=lambda c: c.settings.child_lock, + key="child_lock", + translation_key="child_lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, + capability_fn=lambda c: c.settings.moveup_warning, + key="move_up_warning", + translation_key="move_up_warning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, + capability_fn=lambda c: c.settings.cross_map_border_warning, + key="cross_map_border_warning", + translation_key="cross_map_border_warning", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), + EcovacsSwitchEntityDescription[Capabilities]( + device_capabilities=Capabilities, + capability_fn=lambda c: c.settings.safe_protect, + key="safe_protect", + translation_key="safe_protect", + entity_registry_enabled_default=False, + entity_category=EntityCategory.CONFIG, + ), ) @@ -83,7 +133,7 @@ async def async_setup_entry( class EcovacsSwitchEntity( - EcovacsDescriptionEntity[CapabilitySetEnable], + EcovacsDescriptionEntity[CapabilityDevice, CapabilitySetEnable], SwitchEntity, ): """Ecovacs switch entity.""" diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index 28750d4f9de50b..b3e0d4d96be616 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -5,6 +5,8 @@ import string from typing import TYPE_CHECKING +from deebot_client.capabilities import Capabilities + from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, @@ -30,9 +32,11 @@ def get_supported_entitites( """Return all supported entities for all devices.""" entities: list[EcovacsEntity] = [] - for device in controller.devices: + for device in controller.devices(Capabilities): for description in descriptions: - if capability := description.capability_fn(device.capabilities): + if isinstance(device.capabilities, description.device_capabilities) and ( + capability := description.capability_fn(device.capabilities) + ): entities.append(entity_class(device, capability, description)) return entities diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index a9990bc6fff7bc..0d65d58d84ce58 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -5,7 +5,7 @@ import logging from typing import Any -from deebot_client.capabilities import Capabilities +from deebot_client.capabilities import VacuumCapabilities from deebot_client.device import Device from deebot_client.events import BatteryEvent, FanSpeedEvent, RoomsEvent, StateEvent from deebot_client.models import CleanAction, CleanMode, Room, State @@ -50,7 +50,7 @@ async def async_setup_entry( for device in controller.legacy_devices: await hass.async_add_executor_job(device.connect_and_wait_until_ready) vacuums.append(EcovacsLegacyVacuum(device)) - for device in controller.devices: + for device in controller.devices(VacuumCapabilities): vacuums.append(EcovacsVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) async_add_entities(vacuums) @@ -210,7 +210,7 @@ def send_command( class EcovacsVacuum( - EcovacsEntity[Capabilities], + EcovacsEntity[VacuumCapabilities, VacuumCapabilities], StateVacuumEntity, ): """Ecovacs vacuum.""" @@ -233,7 +233,7 @@ class EcovacsVacuum( key="vacuum", translation_key="vacuum", name=None ) - def __init__(self, device: Device) -> None: + def __init__(self, device: Device[VacuumCapabilities]) -> None: """Initialize the vacuum.""" capabilities = device.capabilities super().__init__(device, capabilities) @@ -349,6 +349,15 @@ async def async_send_command( translation_key="vacuum_send_command_params_required", translation_placeholders={"command": command}, ) + if self._capability.clean.action.area is None: + info = self._device.device_info + name = info.get("nick", info["name"]) + raise ServiceValidationError( + f"Vacuum {name} does not support area capability!", + translation_domain=DOMAIN, + translation_key="vacuum_send_command_area_not_supported", + translation_placeholders={"name": name}, + ) if command in "spot_area": await self._device.execute_command( diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 6d048cc423d792..4bcdd2461cd50e 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -176,6 +176,12 @@ native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), + EcoWittSensorTypes.SOIL_RAWADC: SensorEntityDescription( + key="SOIL_RAWADC", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), EcoWittSensorTypes.SPEED_KPH: SensorEntityDescription( key="SPEED_KPH", device_class=SensorDeviceClass.WIND_SPEED, diff --git a/homeassistant/components/edl21/icons.json b/homeassistant/components/edl21/icons.json new file mode 100644 index 00000000000000..c26a8a8e50bbdb --- /dev/null +++ b/homeassistant/components/edl21/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "sensor": { + "ownership_id": { + "default": "mdi:flash" + }, + "electricity_id": { + "default": "mdi:flash" + }, + "configuration_program_version_number": { + "default": "mdi:flash" + }, + "firmware_version_number": { + "default": "mdi:flash" + }, + "supply_frequency": { + "default": "mdi:sine-wave" + }, + "u_l2_u_l1_phase_angle": { + "default": "mdi:sine-wave" + }, + "u_l3_u_l1_phase_angle": { + "default": "mdi:sine-wave" + }, + "u_l1_i_l1_phase_angle": { + "default": "mdi:sine-wave" + }, + "u_l2_i_l2_phase_angle": { + "default": "mdi:sine-wave" + }, + "u_l3_i_l3_phase_angle": { + "default": "mdi:sine-wave" + }, + "metering_point_id_1": { + "default": "mdi:flash" + }, + "internal_operating_status": { + "default": "mdi:flash" + } + } + } +} diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index c2fab739789e4b..0126c87b8cdc21 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -51,25 +51,21 @@ SensorEntityDescription( key="1-0:0.0.0*255", translation_key="ownership_id", - icon="mdi:flash", entity_registry_enabled_default=False, ), # E=9: Electrity ID SensorEntityDescription( key="1-0:0.0.9*255", translation_key="electricity_id", - icon="mdi:flash", ), # D=2: Program entries SensorEntityDescription( key="1-0:0.2.0*0", translation_key="configuration_program_version_number", - icon="mdi:flash", ), SensorEntityDescription( key="1-0:0.2.0*1", translation_key="firmware_version_number", - icon="mdi:flash", ), # C=1: Active power + # D=7: Current value @@ -138,7 +134,6 @@ SensorEntityDescription( key="1-0:14.7.0*255", translation_key="supply_frequency", - icon="mdi:sine-wave", ), # C=15: Active power absolute # D=7: Instantaneous value @@ -249,38 +244,31 @@ SensorEntityDescription( key="1-0:81.7.1*255", translation_key="u_l2_u_l1_phase_angle", - icon="mdi:sine-wave", ), SensorEntityDescription( key="1-0:81.7.2*255", translation_key="u_l3_u_l1_phase_angle", - icon="mdi:sine-wave", ), SensorEntityDescription( key="1-0:81.7.4*255", translation_key="u_l1_i_l1_phase_angle", - icon="mdi:sine-wave", ), SensorEntityDescription( key="1-0:81.7.15*255", translation_key="u_l2_i_l2_phase_angle", - icon="mdi:sine-wave", ), SensorEntityDescription( key="1-0:81.7.26*255", translation_key="u_l3_i_l3_phase_angle", - icon="mdi:sine-wave", ), # C=96: Electricity-related service entries SensorEntityDescription( key="1-0:96.1.0*255", translation_key="metering_point_id_1", - icon="mdi:flash", ), SensorEntityDescription( key="1-0:96.5.0*255", translation_key="internal_operating_status", - icon="mdi:flash", ), ) diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py index ea10cdb4dc4f3a..00ff6749364766 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -18,7 +18,7 @@ ElectricKiwiHOPDataCoordinator, ) -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SELECT] +PLATFORMS: list[Platform] = [Platform.SELECT, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/electric_kiwi/icons.json b/homeassistant/components/electric_kiwi/icons.json new file mode 100644 index 00000000000000..1932ce19432b4a --- /dev/null +++ b/homeassistant/components/electric_kiwi/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "total_running_balance": { + "default": "mdi:currency-usd" + }, + "total_current_balance": { + "default": "mdi:currency-usd" + }, + "next_billing_date": { + "default": "mdi:calendar" + }, + "hop_power_savings": { + "default": "mdi:percent" + } + } + } +} diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 4f8cc59757d2c6..83431dfd925458 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -52,7 +52,6 @@ class ElectricKiwiAccountSensorEntityDescription( ElectricKiwiAccountSensorEntityDescription( key=ATTR_TOTAL_RUNNING_BALANCE, translation_key="total_running_balance", - icon="mdi:currency-usd", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_DOLLAR, @@ -61,7 +60,6 @@ class ElectricKiwiAccountSensorEntityDescription( ElectricKiwiAccountSensorEntityDescription( key=ATTR_TOTAL_CURRENT_BALANCE, translation_key="total_current_balance", - icon="mdi:currency-usd", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, native_unit_of_measurement=CURRENCY_DOLLAR, @@ -70,7 +68,6 @@ class ElectricKiwiAccountSensorEntityDescription( ElectricKiwiAccountSensorEntityDescription( key=ATTR_NEXT_BILLING_DATE, translation_key="next_billing_date", - icon="mdi:calendar", device_class=SensorDeviceClass.DATE, value_func=lambda account_balance: datetime.strptime( account_balance.next_billing_date, "%Y-%m-%d" @@ -79,7 +76,6 @@ class ElectricKiwiAccountSensorEntityDescription( ElectricKiwiAccountSensorEntityDescription( key=ATTR_HOP_PERCENTAGE, translation_key="hop_power_savings", - icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value_func=lambda account_balance: float( diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py index bea60b94a1ce7e..2a929db4b0a240 100644 --- a/homeassistant/components/eliqonline/sensor.py +++ b/homeassistant/components/eliqonline/sensor.py @@ -1,7 +1,6 @@ """Monitors home energy use for the ELIQ Online service.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging @@ -83,5 +82,5 @@ async def async_update(self) -> None: _LOGGER.debug("Updated power from server %d W", self.native_value) except KeyError: _LOGGER.warning("Invalid response from ELIQ Online API") - except (OSError, asyncio.TimeoutError) as error: + except (OSError, TimeoutError) as error: _LOGGER.warning("Could not connect to the ELIQ Online API: %s", error) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 113fe2ac84e164..03f1f80b4f9dc5 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -296,7 +296,7 @@ def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None: try: if not await async_wait_for_elk_to_sync(elk, LOGIN_TIMEOUT, SYNC_TIMEOUT): return False - except asyncio.TimeoutError as exc: + except TimeoutError as exc: raise ConfigEntryNotReady(f"Timed out connecting to {conf[CONF_HOST]}") from exc elk_temp_unit = elk.panel.temperature_units @@ -389,7 +389,7 @@ def sync_complete() -> None: try: async with asyncio.timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("Timed out waiting for %s event", name) elk.disconnect() raise diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index ac7fc9033304c2..e8d3f8cb0e4a97 100644 --- a/homeassistant/components/elkm1/config_flow.py +++ b/homeassistant/components/elkm1/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Elk-M1 Control integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -244,7 +243,7 @@ async def _async_create_or_error( try: info = await validate_input(user_input, self.unique_id) - except asyncio.TimeoutError: + except TimeoutError: return {"base": "cannot_connect"}, None except InvalidAuth: return {CONF_PASSWORD: "invalid_auth"}, None diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 069fc3177d6544..2be89e7214c280 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -207,25 +207,25 @@ def get_entity_name(self, state: State) -> str: return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return] @cache # pylint: disable=method-cache-max-size-none - def get_exposed_states(self) -> list[State]: + def get_exposed_entity_ids(self) -> list[str]: """Return a list of exposed states.""" state_machine = self.hass.states if self.expose_by_default: return [ - state + state.entity_id for state in state_machine.async_all() if self.is_state_exposed(state) ] - states: list[State] = [] - for entity_id in self.entities: - if (state := state_machine.get(entity_id)) and self.is_state_exposed(state): - states.append(state) - return states + return [ + entity_id + for entity_id in self.entities + if (state := state_machine.get(entity_id)) and self.is_state_exposed(state) + ] @callback def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: - """Clear the cache of exposed states.""" - self.get_exposed_states.cache_clear() + """Clear the cache of exposed entity ids.""" + self.get_exposed_entity_ids.cache_clear() def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 94ac97b6b3679f..873f446aad8a53 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -586,21 +586,17 @@ async def put( # noqa: C901 # Separate call to turn on needed if turn_on_needed: - hass.async_create_task( - hass.services.async_call( - core.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + await hass.services.async_call( + core.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, ) if service is not None: state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity) - hass.async_create_task( - hass.services.async_call(domain, service, data, blocking=True) - ) + await hass.services.async_call(domain, service, data, blocking=False) if state_will_change: # Wait for the state to change. @@ -890,18 +886,11 @@ def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" hass: core.HomeAssistant = request.app["hass"] - - json_response: dict[str, Any] = {} - for cached_state in config.get_exposed_states(): - entity_id = cached_state.entity_id - state = hass.states.get(entity_id) - assert state is not None - - json_response[config.entity_id_to_number(entity_id)] = state_to_json( - config, state - ) - - return json_response + return { + config.entity_id_to_number(entity_id): state_to_json(config, state) + for entity_id in config.get_exposed_entity_ids() + if (state := hass.states.get(entity_id)) + } def hue_brightness_to_hass(value: int) -> int: @@ -934,7 +923,7 @@ def _async_event_changed(event: EventType[EventStateChangedData]) -> None: try: async with asyncio.timeout(STATE_CHANGE_WAIT_TIMEOUT): await ev.wait() - except asyncio.TimeoutError: + except TimeoutError: pass finally: unsub() diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 834a9bbb1ebc89..b684ad5ab8fe29 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -355,20 +355,19 @@ def _update_cost(self) -> None: return if ( - state_class != SensorStateClass.TOTAL_INCREASING - and energy_state.attributes.get(ATTR_LAST_RESET) - != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) - ): - # Energy meter was reset, reset cost sensor too - energy_state_copy = copy.copy(energy_state) - energy_state_copy.state = "0.0" - self._reset(energy_state_copy) - elif state_class == SensorStateClass.TOTAL_INCREASING and reset_detected( - self.hass, - cast(str, self._config[self._adapter.stat_energy_key]), - energy, - float(self._last_energy_sensor_state.state), - self._last_energy_sensor_state, + ( + state_class != SensorStateClass.TOTAL_INCREASING + and energy_state.attributes.get(ATTR_LAST_RESET) + != self._last_energy_sensor_state.attributes.get(ATTR_LAST_RESET) + ) + or state_class == SensorStateClass.TOTAL_INCREASING + and reset_detected( + self.hass, + cast(str, self._config[self._adapter.stat_energy_key]), + energy, + float(self._last_energy_sensor_state.state), + self._last_energy_sensor_state, + ) ): # Energy meter was reset, reset cost sensor too energy_state_copy = copy.copy(energy_state) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index a4ee4d0d15f302..5d9cd81013d5c9 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -61,7 +61,8 @@ async def async_get_energy_platforms( """Get energy platforms.""" platforms: dict[str, GetSolarForecastType] = {} - async def _process_energy_platform( + @callback + def _process_energy_platform( hass: HomeAssistant, domain: str, platform: ModuleType ) -> None: """Process energy platforms.""" diff --git a/homeassistant/components/energyzero/diagnostics.py b/homeassistant/components/energyzero/diagnostics.py index 3b0c05b73680e4..b4018a32d3d190 100644 --- a/homeassistant/components/energyzero/diagnostics.py +++ b/homeassistant/components/energyzero/diagnostics.py @@ -21,6 +21,7 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: Returns: The gas market price value. + """ if not data.gas_today: return None diff --git a/homeassistant/components/energyzero/icons.json b/homeassistant/components/energyzero/icons.json new file mode 100644 index 00000000000000..bac061dd31886a --- /dev/null +++ b/homeassistant/components/energyzero/icons.json @@ -0,0 +1,16 @@ +{ + "entity": { + "sensor": { + "percentage_of_max": { + "default": "mdi:percent" + }, + "hours_priced_equal_or_lower": { + "default": "mdi:clock" + } + } + }, + "services": { + "get_gas_prices": "mdi:gas-station", + "get_energy_prices": "mdi:lightning-bolt" + } +} diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 59c44c1aad87ce..005abb62e91853 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -117,7 +117,6 @@ class EnergyZeroSensorEntityDescription( translation_key="percentage_of_max", service_type="today_energy", native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", value_fn=lambda data: data.energy_today.pct_of_max_price, ), EnergyZeroSensorEntityDescription( @@ -125,7 +124,6 @@ class EnergyZeroSensorEntityDescription( translation_key="hours_priced_equal_or_lower", service_type="today_energy", native_unit_of_measurement=UnitOfTime.HOURS, - icon="mdi:clock", value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower, ), ) @@ -140,6 +138,7 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None: Returns: The gas market price value. + """ if data.gas_today is None: return None diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5921de15bde6cb..ee1966d5e519ed 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -136,10 +136,6 @@ async def async_step_user( host = (user_input or {}).get(CONF_HOST) or self.ip_address or "" if user_input is not None: - if not self._reauth_entry: - if host in self._async_current_hosts(): - return self.async_abort(reason="already_configured") - try: envoy = await validate_input( self.hass, @@ -170,7 +166,15 @@ async def async_step_user( name = self._async_envoy_name() if self.unique_id: - self._abort_if_unique_id_configured({CONF_HOST: host}) + # If envoy exists in configuration update fields and exit + self._abort_if_unique_id_configured( + { + CONF_HOST: host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + error="reauth_successful", + ) # CONF_NAME is still set for legacy backwards compatibility return self.async_create_entry( diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 61c8a07cfbb9ce..63e10547ead102 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "iot_class": "local_polling", "loggers": ["pyenphase"], - "requirements": ["pyenphase==1.19.0"], + "requirements": ["pyenphase==1.19.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index c2ecf8e8a13d7d..c0d1c66deae200 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -16,7 +16,14 @@ EnvoySystemConsumption, EnvoySystemProduction, ) -from pyenphase.const import PHASENAMES, PhaseNames +from pyenphase.const import PHASENAMES +from pyenphase.models.meters import ( + CtMeterStatus, + CtState, + CtStatusFlags, + CtType, + EnvoyMeterData, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,7 +35,9 @@ from homeassistant.const import ( PERCENTAGE, UnitOfApparentPower, + UnitOfElectricPotential, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfTemperature, ) @@ -87,7 +96,7 @@ class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] - on_phase: PhaseNames | None + on_phase: str | None @dataclass(frozen=True) @@ -145,7 +154,7 @@ class EnvoyProductionSensorEntityDescription( PRODUCTION_PHASE_SENSORS = { - (on_phase := PhaseNames(PHASENAMES[phase])): [ + (on_phase := PHASENAMES[phase]): [ replace( sensor, key=f"{sensor.key}_l{phase + 1}", @@ -164,7 +173,7 @@ class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] - on_phase: PhaseNames | None + on_phase: str | None @dataclass(frozen=True) @@ -222,7 +231,7 @@ class EnvoyConsumptionSensorEntityDescription( CONSUMPTION_PHASE_SENSORS = { - (on_phase := PhaseNames(PHASENAMES[phase])): [ + (on_phase := PHASENAMES[phase]): [ replace( sensor, key=f"{sensor.key}_l{phase + 1}", @@ -236,6 +245,151 @@ class EnvoyConsumptionSensorEntityDescription( } +@dataclass(frozen=True) +class EnvoyCTRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[ + [EnvoyMeterData], + int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None, + ] + on_phase: str | None + + +@dataclass(frozen=True) +class EnvoyCTSensorEntityDescription(SensorEntityDescription, EnvoyCTRequiredKeysMixin): + """Describes an Envoy CT sensor entity.""" + + +CT_NET_CONSUMPTION_SENSORS = ( + EnvoyCTSensorEntityDescription( + key="lifetime_net_consumption", + translation_key="lifetime_net_consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + suggested_display_precision=3, + value_fn=lambda ct: ct.energy_delivered, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="lifetime_net_production", + translation_key="lifetime_net_production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, + suggested_display_precision=3, + value_fn=lambda ct: ct.energy_received, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="net_consumption", + translation_key="net_consumption", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=3, + value_fn=lambda ct: ct.active_power, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="frequency", + translation_key="net_ct_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda ct: ct.frequency, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="voltage", + translation_key="net_ct_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=lambda ct: ct.voltage, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="net_consumption_ct_metering_status", + translation_key="net_ct_metering_status", + device_class=SensorDeviceClass.ENUM, + options=list(CtMeterStatus), + entity_registry_enabled_default=False, + value_fn=lambda ct: ct.metering_status, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="net_consumption_ct_status_flags", + translation_key="net_ct_status_flags", + state_class=None, + entity_registry_enabled_default=False, + value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), + on_phase=None, + ), +) + + +CT_NET_CONSUMPTION_PHASE_SENSORS = { + (on_phase := PHASENAMES[phase]): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + entity_registry_enabled_default=False, + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(CT_NET_CONSUMPTION_SENSORS) + ] + for phase in range(0, 3) +} + +CT_PRODUCTION_SENSORS = ( + EnvoyCTSensorEntityDescription( + key="production_ct_metering_status", + translation_key="production_ct_metering_status", + device_class=SensorDeviceClass.ENUM, + options=list(CtMeterStatus), + entity_registry_enabled_default=False, + value_fn=lambda ct: ct.metering_status, + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="production_ct_status_flags", + translation_key="production_ct_status_flags", + state_class=None, + entity_registry_enabled_default=False, + value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), + on_phase=None, + ), +) + +CT_PRODUCTION_PHASE_SENSORS = { + (on_phase := PHASENAMES[phase]): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + entity_registry_enabled_default=False, + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(CT_PRODUCTION_SENSORS) + ] + for phase in range(0, 3) +} + + @dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" @@ -408,7 +562,7 @@ async def async_setup_entry( entities.extend( EnvoyProductionPhaseEntity(coordinator, description) for use_phase, phase in envoy_data.system_production_phases.items() - for description in PRODUCTION_PHASE_SENSORS[PhaseNames(use_phase)] + for description in PRODUCTION_PHASE_SENSORS[use_phase] if phase is not None ) # For each consumption phase reported add consumption entities @@ -416,9 +570,39 @@ async def async_setup_entry( entities.extend( EnvoyConsumptionPhaseEntity(coordinator, description) for use_phase, phase in envoy_data.system_consumption_phases.items() - for description in CONSUMPTION_PHASE_SENSORS[PhaseNames(use_phase)] + for description in CONSUMPTION_PHASE_SENSORS[use_phase] if phase is not None ) + # Add net consumption CT entities + if ctmeter := envoy_data.ctmeter_consumption: + entities.extend( + EnvoyConsumptionCTEntity(coordinator, description) + for description in CT_NET_CONSUMPTION_SENSORS + if ctmeter.measurement_type == CtType.NET_CONSUMPTION + ) + # For each net consumption ct phase reported add net consumption entities + if phase_data := envoy_data.ctmeter_consumption_phases: + entities.extend( + EnvoyConsumptionCTPhaseEntity(coordinator, description) + for use_phase, phase in phase_data.items() + for description in CT_NET_CONSUMPTION_PHASE_SENSORS[use_phase] + if phase.measurement_type == CtType.NET_CONSUMPTION + ) + # Add production CT entities + if ctmeter := envoy_data.ctmeter_production: + entities.extend( + EnvoyProductionCTEntity(coordinator, description) + for description in CT_PRODUCTION_SENSORS + if ctmeter.measurement_type == CtType.PRODUCTION + ) + # For each production ct phase reported add production ct entities + if phase_data := envoy_data.ctmeter_production_phases: + entities.extend( + EnvoyProductionCTPhaseEntity(coordinator, description) + for use_phase, phase in phase_data.items() + for description in CT_PRODUCTION_PHASE_SENSORS[use_phase] + if phase.measurement_type == CtType.PRODUCTION + ) if envoy_data.inverters: entities.extend( @@ -549,6 +733,74 @@ def native_value(self) -> int | None: return self.entity_description.value_fn(system_consumption) +class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity): + """Envoy net consumption CT entity.""" + + entity_description: EnvoyCTSensorEntityDescription + + @property + def native_value( + self, + ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: + """Return the state of the CT sensor.""" + if (ctmeter := self.data.ctmeter_consumption) is None: + return None + return self.entity_description.value_fn(ctmeter) + + +class EnvoyConsumptionCTPhaseEntity(EnvoySystemSensorEntity): + """Envoy net consumption CT phase entity.""" + + entity_description: EnvoyCTSensorEntityDescription + + @property + def native_value( + self, + ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: + """Return the state of the CT phase sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + if (ctmeter := self.data.ctmeter_consumption_phases) is None: + return None + return self.entity_description.value_fn( + ctmeter[self.entity_description.on_phase] + ) + + +class EnvoyProductionCTEntity(EnvoySystemSensorEntity): + """Envoy net consumption CT entity.""" + + entity_description: EnvoyCTSensorEntityDescription + + @property + def native_value( + self, + ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: + """Return the state of the CT sensor.""" + if (ctmeter := self.data.ctmeter_production) is None: + return None + return self.entity_description.value_fn(ctmeter) + + +class EnvoyProductionCTPhaseEntity(EnvoySystemSensorEntity): + """Envoy net consumption CT phase entity.""" + + entity_description: EnvoyCTSensorEntityDescription + + @property + def native_value( + self, + ) -> int | float | str | CtType | CtMeterStatus | CtStatusFlags | None: + """Return the state of the CT phase sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + if (ctmeter := self.data.ctmeter_production_phases) is None: + return None + return self.entity_description.value_fn( + ctmeter[self.entity_description.on_phase] + ) + + class EnvoyInverterEntity(EnvoySensorBaseEntity): """Envoy inverter entity.""" diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index f3e78432f906a7..b0854f64f24297 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -143,6 +143,60 @@ "lifetime_consumption_phase": { "name": "Lifetime energy consumption {phase_name}" }, + "lifetime_net_consumption": { + "name": "Lifetime net energy consumption" + }, + "lifetime_net_production": { + "name": "Lifetime net energy production" + }, + "net_consumption": { + "name": "Current net power consumption" + }, + "net_ct_frequency": { + "name": "Frequency net consumption CT" + }, + "net_ct_voltage": { + "name": "Voltage net consumption CT" + }, + "net_ct_metering_status": { + "name": "Metering status net consumption CT" + }, + "net_ct_status_flags": { + "name": "Meter status flags active net consumption CT" + }, + "production_ct_metering_status": { + "name": "Metering status production CT" + }, + "production_ct_status_flags": { + "name": "Meter status flags active production CT" + }, + "lifetime_net_consumption_phase": { + "name": "Lifetime net energy consumption {phase_name}" + }, + "lifetime_net_production_phase": { + "name": "Lifetime net energy production {phase_name}" + }, + "net_consumption_phase": { + "name": "Current net power consumption {phase_name}" + }, + "net_ct_frequency_phase": { + "name": "Frequency net consumption CT {phase_name}" + }, + "net_ct_voltage_phase": { + "name": "Voltage net consumption CT {phase_name}" + }, + "net_ct_metering_status_phase": { + "name": "Metering status net consumption CT {phase_name}" + }, + "net_ct_status_flags_phase": { + "name": "Meter status flags active net consumption CT {phase_name}" + }, + "production_ct_metering_status_phase": { + "name": "Metering status production CT {phase_name}" + }, + "production_ct_status_flags_phase": { + "name": "Meter status flags active production CT {phase_name}" + }, "reserve_soc": { "name": "Reserve battery level" }, diff --git a/homeassistant/components/environment_canada/icons.json b/homeassistant/components/environment_canada/icons.json new file mode 100644 index 00000000000000..5e23a96bcfbdff --- /dev/null +++ b/homeassistant/components/environment_canada/icons.json @@ -0,0 +1,24 @@ +{ + "entity": { + "sensor": { + "advisories": { + "default": "mdi:bell-alert" + }, + "endings": { + "default": "mdi:alert-circle-check" + }, + "statements": { + "default": "mdi:bell-alert" + }, + "warnings": { + "default": "mdi:alert-octagon" + }, + "watches": { + "default": "mdi:alert" + } + } + }, + "services": { + "set_radar_type": "mdi:radar" + } +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 9ec4971f5731fe..143090cc227a58 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -235,35 +235,30 @@ def _get_aqhi_value(data): ECSensorEntityDescription( key="advisories", translation_key="advisories", - icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("advisories", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="endings", translation_key="endings", - icon="mdi:alert-circle-check", value_fn=lambda data: data.alerts.get("endings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="statements", translation_key="statements", - icon="mdi:bell-alert", value_fn=lambda data: data.alerts.get("statements", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="warnings", translation_key="warnings", - icon="mdi:alert-octagon", value_fn=lambda data: data.alerts.get("warnings", {}).get("value"), transform=len, ), ECSensorEntityDescription( key="watches", translation_key="watches", - icon="mdi:alert", value_fn=lambda data: data.alerts.get("watches", {}).get("value"), transform=len, ), diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index ad65bf702750a9..273dd4f0d0aff6 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -160,9 +160,7 @@ def state(self) -> str: state = STATE_ALARM_ARMED_AWAY elif self._info["status"]["armed_stay"]: state = STATE_ALARM_ARMED_HOME - elif self._info["status"]["exit_delay"]: - state = STATE_ALARM_PENDING - elif self._info["status"]["entry_delay"]: + elif self._info["status"]["exit_delay"] or self._info["status"]["entry_delay"]: state = STATE_ALARM_PENDING elif self._info["status"]["alpha"]: state = STATE_ALARM_DISARMED diff --git a/homeassistant/components/epson/icons.json b/homeassistant/components/epson/icons.json new file mode 100644 index 00000000000000..a9237edcfd17ae --- /dev/null +++ b/homeassistant/components/epson/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "select_cmode": "mdi:palette" + } +} diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py index 021cfd267648ff..b204ae196e827e 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -31,7 +31,6 @@ DOMAIN, ESCEA_FIREPLACE, ESCEA_MANUFACTURER, - ICON, ) _LOGGER = logging.getLogger(__name__) @@ -78,7 +77,7 @@ class ControllerEntity(ClimateEntity): _attr_has_entity_name = True _attr_name = None _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] - _attr_icon = ICON + _attr_translation_key = "fireplace" _attr_precision = PRECISION_WHOLE _attr_should_poll = False _attr_supported_features = ( diff --git a/homeassistant/components/escea/config_flow.py b/homeassistant/components/escea/config_flow.py index 8766c30c04ac48..eb50e7d0fdcfa9 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -31,7 +31,7 @@ def dispatch_discovered(_): discovery_service = await async_start_discovery_service(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() diff --git a/homeassistant/components/escea/const.py b/homeassistant/components/escea/const.py index c35e77e2719881..363dd166eba54c 100644 --- a/homeassistant/components/escea/const.py +++ b/homeassistant/components/escea/const.py @@ -3,7 +3,6 @@ DOMAIN = "escea" ESCEA_MANUFACTURER = "Escea" ESCEA_FIREPLACE = "Escea Fireplace" -ICON = "mdi:fire" DATA_DISCOVERY_SERVICE = "escea_discovery" diff --git a/homeassistant/components/escea/icons.json b/homeassistant/components/escea/icons.json new file mode 100644 index 00000000000000..a9ac95f216c0df --- /dev/null +++ b/homeassistant/components/escea/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "climate": { + "fireplace": { + "default": "mdi:fire" + } + } + } +} diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index e4f44dfd1fd486..58f63446da765f 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -115,42 +115,42 @@ def state(self) -> str | None: async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.DISARM, code ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_HOME, code ) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_AWAY, code ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_NIGHT, code ) async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code ) async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.ARM_VACATION, code ) async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" - await self._client.alarm_control_panel_command( + self._client.alarm_control_panel_command( self._key, AlarmControlPanelCommand.TRIGGER, code ) diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py index 24524233a70e52..37a555f3115a45 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -21,7 +21,8 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: callback() -async def async_connect_scanner( +@hass_callback +def async_connect_scanner( hass: HomeAssistant, entry_data: RuntimeEntryData, cli: APIClient, @@ -29,7 +30,7 @@ async def async_connect_scanner( cache: ESPHomeBluetoothCache, ) -> CALLBACK_TYPE: """Connect scanner.""" - client_data = await connect_scanner(cli, device_info, cache, entry_data.available) + client_data = connect_scanner(cli, device_info, cache, entry_data.available) entry_data.bluetooth_device = client_data.bluetooth_device client_data.disconnect_callbacks = entry_data.disconnect_callbacks scanner = client_data.scanner diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index a55acf067f05af..d59e135d7484be 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -54,4 +54,4 @@ def _on_device_update(self) -> None: async def async_press(self) -> None: """Press the button.""" - await self._client.button_command(self._key) + self._client.button_command(self._key) diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 98a4c26621df89..0b9c2995dac606 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine +from collections.abc import Callable from functools import partial from typing import Any @@ -70,14 +70,14 @@ async def async_camera_image( return await self._async_request_image(self._client.request_single_image) async def _async_request_image( - self, request_method: Callable[[], Coroutine[Any, Any, None]] + self, request_method: Callable[[], None] ) -> bytes | None: """Wait for an image to be available and return it.""" if not self.available: return None image_future = self._loop.create_future() self._image_futures.append(image_future) - await request_method() + request_method() if not await image_future: return None return self._state.data diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 9c2177800f3fe5..b9952004569ed1 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -286,15 +286,15 @@ async def async_set_temperature(self, **kwargs: Any) -> None: data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] if ATTR_TARGET_TEMP_HIGH in kwargs: data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] - await self._client.climate_command(**data) + self._client.climate_command(**data) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._client.climate_command(key=self._key, target_humidity=humidity) + self._client.climate_command(key=self._key, target_humidity=humidity) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" - await self._client.climate_command( + self._client.climate_command( key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) ) @@ -305,7 +305,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: kwargs["custom_preset"] = preset_mode else: kwargs["preset"] = _PRESETS.from_hass(preset_mode) - await self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" @@ -314,10 +314,10 @@ async def async_set_fan_mode(self, fan_mode: str) -> None: kwargs["custom_fan_mode"] = fan_mode else: kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) - await self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" - await self._client.climate_command( + self._client.climate_command( key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) ) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4dee3958515a70..77c3fee0afcca4 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -96,31 +96,29 @@ def current_cover_tilt_position(self) -> int | None: async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._client.cover_command(key=self._key, position=1.0) + self._client.cover_command(key=self._key, position=1.0) async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._client.cover_command(key=self._key, position=0.0) + self._client.cover_command(key=self._key, position=0.0) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._client.cover_command(key=self._key, stop=True) + self._client.cover_command(key=self._key, stop=True) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - await self._client.cover_command( - key=self._key, position=kwargs[ATTR_POSITION] / 100 - ) + self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self._client.cover_command(key=self._key, tilt=1.0) + self._client.cover_command(key=self._key, tilt=1.0) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self._client.cover_command(key=self._key, tilt=0.0) + self._client.cover_command(key=self._key, tilt=0.0) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - await self._client.cover_command(key=self._key, tilt=tilt_position / 100) + self._client.cover_command(key=self._key, tilt=tilt_position / 100) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 14602077a948ab..7b06fadb33ff9c 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -65,10 +65,8 @@ def async_static_info_updated( device_info = entry_data.device_info if TYPE_CHECKING: assert device_info is not None - hass.async_create_task( - entry_data.async_remove_entities( - hass, current_infos.values(), device_info.mac_address - ) + entry_data.async_remove_entities( + hass, current_infos.values(), device_info.mac_address ) # Then update the actual info @@ -210,12 +208,6 @@ async def async_added_to_hass(self) -> None: key = self._key static_info = self._static_info - self.async_on_remove( - entry_data.async_register_key_static_info_remove_callback( - static_info, - functools.partial(self.async_remove, force_remove=True), - ) - ) self.async_on_remove( async_dispatcher_connect( hass, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 940b1560ba49b4..a15f68fd6cc2cb 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -123,9 +123,6 @@ class RuntimeEntryData: entity_info_callbacks: dict[ type[EntityInfo], list[Callable[[list[EntityInfo]], None]] ] = field(default_factory=dict) - entity_info_key_remove_callbacks: dict[ - tuple[type[EntityInfo], int], list[Callable[[], Coroutine[Any, Any, None]]] - ] = field(default_factory=dict) entity_info_key_updated_callbacks: dict[ tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] ] = field(default_factory=dict) @@ -177,18 +174,6 @@ def _async_unsubscribe_register_static_info( """Unsubscribe to when static info is registered.""" callbacks.remove(callback_) - @callback - def async_register_key_static_info_remove_callback( - self, - static_info: EntityInfo, - callback_: Callable[[], Coroutine[Any, Any, None]], - ) -> CALLBACK_TYPE: - """Register to receive callbacks when static info is removed for a specific key.""" - callback_key = (type(static_info), static_info.key) - callbacks = self.entity_info_key_remove_callbacks.setdefault(callback_key, []) - callbacks.append(callback_) - return partial(self._async_unsubscribe_static_key_remove, callbacks, callback_) - @callback def _async_unsubscribe_static_key_remove( self, @@ -243,7 +228,8 @@ def _async_unsubscribe_assist_pipeline_update( """Unsubscribe to assist pipeline updates.""" self.assist_pipeline_update_callbacks.remove(update_callback) - async def async_remove_entities( + @callback + def async_remove_entities( self, hass: HomeAssistant, static_infos: Iterable[EntityInfo], mac: str ) -> None: """Schedule the removal of an entity.""" @@ -255,14 +241,6 @@ async def async_remove_entities( ): ent_reg.async_remove(entry) - callbacks: list[Coroutine[Any, Any, None]] = [] - for static_info in static_infos: - callback_key = (type(static_info), static_info.key) - if key_callbacks := self.entity_info_key_remove_callbacks.get(callback_key): - callbacks.extend([callback_() for callback_ in key_callbacks]) - if callbacks: - await asyncio.gather(*callbacks) - @callback def async_update_entity_infos(self, static_infos: Iterable[EntityInfo]) -> None: """Call static info updated callbacks.""" @@ -406,7 +384,7 @@ async def async_load_from_store(self) -> tuple[list[EntityInfo], list[UserServic ] return infos, services - async def async_save_to_store(self) -> None: + def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" if TYPE_CHECKING: assert self.device_info is not None diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 4c44134374a422..90cda53dee67fd 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -77,7 +77,7 @@ async def _async_set_percentage(self, percentage: int | None) -> None: ORDERED_NAMED_FAN_SPEEDS, percentage ) data["speed"] = named_speed - await self._client.fan_command(**data) + self._client.fan_command(**data) async def async_turn_on( self, @@ -90,21 +90,21 @@ async def async_turn_on( async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - await self._client.fan_command(key=self._key, state=False) + self._client.fan_command(key=self._key, state=False) async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - await self._client.fan_command(key=self._key, oscillating=oscillating) + self._client.fan_command(key=self._key, oscillating=oscillating) async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" - await self._client.fan_command( + self._client.fan_command( key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - await self._client.fan_command(key=self._key, preset_mode=preset_mode) + self._client.fan_command(key=self._key, preset_mode=preset_mode) @property @esphome_state_property diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 2771e0ccc6bff8..4f047bad757950 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -285,7 +285,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: # (fewest capabilities set) data["color_mode"] = _least_complex_color_mode(color_modes) - await self._client.light_command(**data) + self._client.light_command(**data) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" @@ -294,7 +294,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: data["transition_length"] = kwargs[ATTR_TRANSITION] - await self._client.light_command(**data) + self._client.light_command(**data) @property @esphome_state_property diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index 6a0d100e6799f9..55177fd9a518ea 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -71,13 +71,13 @@ def is_jammed(self) -> bool | None: async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._client.lock_command(self._key, LockCommand.LOCK) + self._client.lock_command(self._key, LockCommand.LOCK) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE, None) - await self._client.lock_command(self._key, LockCommand.UNLOCK, code) + self._client.lock_command(self._key, LockCommand.UNLOCK, code) async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - await self._client.lock_command(self._key, LockCommand.OPEN) + self._client.lock_command(self._key, LockCommand.OPEN) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 59f37d3a078454..bd01bea87959c6 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine from functools import partial import logging from typing import TYPE_CHECKING, Any, NamedTuple @@ -21,7 +20,6 @@ UserService, UserServiceArgType, VoiceAssistantAudioSettings, - VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -34,14 +32,7 @@ EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED, ) -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HomeAssistant, - ServiceCall, - State, - callback, -) +from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv @@ -267,7 +258,8 @@ def async_on_service_call(self, service: HomeassistantServiceCall) -> None: service_data, ) - async def _send_home_assistant_state( + @callback + def _send_home_assistant_state( self, entity_id: str, attribute: str | None, state: State | None ) -> None: """Forward Home Assistant states to ESPHome.""" @@ -283,9 +275,10 @@ async def _send_home_assistant_state( else: send_state = attr_val - await self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) + self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) - async def _send_home_assistant_state_event( + @callback + def _send_home_assistant_state_event( self, attribute: str | None, event: EventType[EventStateChangedData], @@ -306,9 +299,7 @@ async def _send_home_assistant_state_event( ): return - await self._send_home_assistant_state( - event.data["entity_id"], attribute, new_state - ) + self._send_home_assistant_state(event.data["entity_id"], attribute, new_state) @callback def async_on_state_subscription( @@ -324,17 +315,10 @@ def async_on_state_subscription( ) ) # Send initial state - hass.async_create_task( - self._send_home_assistant_state( - entity_id, attribute, hass.states.get(entity_id) - ) + self._send_home_assistant_state( + entity_id, attribute, hass.states.get(entity_id) ) - def _handle_pipeline_event( - self, event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - self.cli.send_voice_assistant_event(event_type, data) - def _handle_pipeline_finished(self) -> None: self.entry_data.async_set_assist_pipeline_state(False) @@ -347,6 +331,7 @@ async def _handle_pipeline_start( conversation_id: str, flags: int, audio_settings: VoiceAssistantAudioSettings, + wake_word_phrase: str | None, ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: @@ -358,7 +343,7 @@ async def _handle_pipeline_start( self.voice_assistant_udp_server = VoiceAssistantUDPServer( hass, self.entry_data, - self._handle_pipeline_event, + self.cli.send_voice_assistant_event, self._handle_pipeline_finished, ) port = await self.voice_assistant_udp_server.start_server() @@ -370,6 +355,7 @@ async def _handle_pipeline_start( conversation_id=conversation_id or None, flags=flags, audio_settings=audio_settings, + wake_word_phrase=wake_word_phrase, ), "esphome.voice_assistant_udp_server.run_pipeline", ) @@ -467,44 +453,33 @@ async def _on_connnect(self) -> None: reconnect_logic.name = device_info.name self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) await entry_data.async_update_static_infos( hass, entry, entity_infos, device_info.mac_address ) _setup_services(hass, entry_data, services) - setup_coros_with_disconnect_callbacks: list[ - Coroutine[Any, Any, CALLBACK_TYPE] - ] = [] if device_info.bluetooth_proxy_feature_flags_compat(api_version): - setup_coros_with_disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( async_connect_scanner( hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache ) ) if device_info.voice_assistant_version: - setup_coros_with_disconnect_callbacks.append( + entry_data.disconnect_callbacks.add( cli.subscribe_voice_assistant( self._handle_pipeline_start, self._handle_pipeline_stop, ) ) - setup_results = await asyncio.gather( - *setup_coros_with_disconnect_callbacks, - cli.subscribe_states(entry_data.async_update_state), - cli.subscribe_service_calls(self.async_on_service_call), - cli.subscribe_home_assistant_states(self.async_on_state_subscription), - ) + cli.subscribe_states(entry_data.async_update_state) + cli.subscribe_service_calls(self.async_on_service_call) + cli.subscribe_home_assistant_states(self.async_on_state_subscription) - for result_idx in range(len(setup_coros_with_disconnect_callbacks)): - cancel_callback = setup_results[result_idx] - if TYPE_CHECKING: - assert cancel_callback is not None - entry_data.disconnect_callbacks.add(cancel_callback) - - hass.async_create_task(entry_data.async_save_to_store()) + entry_data.async_save_to_store() _async_check_firmware_version(hass, device_info, api_version) _async_check_using_api_password(hass, device_info, bool(self.password)) @@ -712,11 +687,12 @@ class ServiceMetadata(NamedTuple): } -async def execute_service( +@callback +def execute_service( entry_data: RuntimeEntryData, service: UserService, call: ServiceCall ) -> None: """Execute a service on a node.""" - await entry_data.client.execute_service(service, call.data) + entry_data.client.execute_service(service, call.data) def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 35b8e91f12b027..a1841306f0c3cf 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -11,13 +11,14 @@ } ], "documentation": "https://www.home-assistant.io/integrations/esphome", + "import_executor": true, "integration_type": "device", "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==21.0.2", + "aioesphomeapi==23.0.0", "esphome-dashboard-api==1.2.3", - "bleak-esphome==0.4.1" + "bleak-esphome==1.0.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index c77625b14ddb01..208f1edebeb784 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -106,7 +106,7 @@ async def async_play_media( media_id = async_process_play_media_url(self.hass, media_id) - await self._client.media_player_command( + self._client.media_player_command( self._key, media_url=media_id, ) @@ -125,29 +125,23 @@ async def async_browse_media( async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - await self._client.media_player_command(self._key, volume=volume) + self._client.media_player_command(self._key, volume=volume) async def async_media_pause(self) -> None: """Send pause command.""" - await self._client.media_player_command( - self._key, command=MediaPlayerCommand.PAUSE - ) + self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE) async def async_media_play(self) -> None: """Send play command.""" - await self._client.media_player_command( - self._key, command=MediaPlayerCommand.PLAY - ) + self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY) async def async_media_stop(self) -> None: """Send stop command.""" - await self._client.media_player_command( - self._key, command=MediaPlayerCommand.STOP - ) + self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - await self._client.media_player_command( + self._client.media_player_command( self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, ) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index f1902bdb39dcdb..2619dbad0457b3 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -79,4 +79,4 @@ def native_value(self) -> float | None: async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - await self._client.number_command(self._key, value) + self._client.number_command(self._key, value) diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index 3d4d296bb87c88..43965a11df4b30 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -67,7 +67,7 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._client.select_command(self._key, option) + self._client.select_command(self._key, option) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index efc77ff53b859f..d2be19a3fb3887 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,7 +1,7 @@ """Support for esphome sensors.""" from __future__ import annotations -from datetime import datetime +from datetime import date, datetime import math from aioesphomeapi import ( @@ -106,9 +106,27 @@ def native_value(self) -> datetime | str | None: class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): """A text sensor implementation for ESPHome.""" + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_device_class = try_parse_enum( + SensorDeviceClass, static_info.device_class + ) + @property @esphome_state_property - def native_value(self) -> str | None: + def native_value(self) -> str | datetime | date | None: """Return the state of the entity.""" state = self._state - return None if state.missing_state else state.state + if state.missing_state: + return None + if self._attr_device_class is SensorDeviceClass.TIMESTAMP: + return dt_util.parse_datetime(state.state) + if ( + self._attr_device_class is SensorDeviceClass.DATE + and (value := dt_util.parse_datetime(state.state)) is not None + ): + return value.date() + return state.state diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index b2ceaf0fced26c..a6ecd86f264572 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -49,8 +49,8 @@ def is_on(self) -> bool | None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._client.switch_command(self._key, True) + self._client.switch_command(self._key, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._client.switch_command(self._key, False) + self._client.switch_command(self._key, False) diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index 49049eecfd4ae3..337cbb26fee4dd 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -60,4 +60,4 @@ def native_value(self) -> str | None: async def async_set_value(self, value: str) -> None: """Update the current value.""" - await self._client.text_command(self._key, value) + self._client.text_command(self._key, value) diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index ea052522e765b6..a444c98b987c7c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -import logging from typing import Any from aioesphomeapi import DeviceInfo as ESPHomeDeviceInfo, EntityInfo @@ -29,8 +28,6 @@ NO_FEATURES = UpdateEntityFeature(0) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 7c5c74d58eead8..15b580a0601df8 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -160,10 +160,10 @@ def close(self) -> None: async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if not self.is_running: - raise RuntimeError("Not running") - while data := await self.queue.get(): + if not self.is_running: + break + yield data def _event_callback(self, event: PipelineEvent) -> None: @@ -237,6 +237,7 @@ async def run_pipeline( conversation_id: str | None, flags: int = 0, audio_settings: VoiceAssistantAudioSettings | None = None, + wake_word_phrase: str | None = None, ) -> None: """Run the Voice Assistant pipeline.""" if audio_settings is None or audio_settings.volume_multiplier == 0: @@ -273,6 +274,7 @@ async def run_pipeline( tts_audio_output=tts_audio_output, start_stage=start_stage, wake_word_settings=WakeWordSettings(timeout=5), + wake_word_phrase=wake_word_phrase, audio_settings=AudioSettings( noise_suppression_level=audio_settings.noise_suppression_level, auto_gain_dbfs=audio_settings.auto_gain, diff --git a/homeassistant/components/eufylife_ble/icons.json b/homeassistant/components/eufylife_ble/icons.json new file mode 100644 index 00000000000000..e8e83232656ed7 --- /dev/null +++ b/homeassistant/components/eufylife_ble/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "heart_rate": { + "default": "mdi:heart-pulse" + } + } + } +} diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index 3278f1c13871c8..69b88bb01f651e 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -159,7 +159,6 @@ class EufyLifeHeartRateSensorEntity(RestoreSensor, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" _attr_translation_key = "heart_rate" - _attr_icon = "mdi:heart-pulse" _attr_native_unit_of_measurement = "bpm" def __init__(self, data: EufyLifeData) -> None: diff --git a/homeassistant/components/evil_genius_labs/config_flow.py b/homeassistant/components/evil_genius_labs/config_flow.py index beb16115bd711b..ab2e116b2a6717 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -63,7 +63,7 @@ async def async_step_user( try: info = await validate_input(self.hass, user_input) - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "timeout" except CannotConnect: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index df6ddc38de74b9..5cc23b5d73cc69 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -33,7 +33,6 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", translation_key="ground_delay", - icon="mdi:airport", is_on_fn=lambda airport: airport.ground_delay.status, extra_state_attributes_fn=lambda airport: { "average": airport.ground_delay.average, @@ -43,7 +42,6 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", translation_key="ground_stop", - icon="mdi:airport", is_on_fn=lambda airport: airport.ground_stop.status, extra_state_attributes_fn=lambda airport: { "endtime": airport.ground_stop.endtime, @@ -53,7 +51,6 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", translation_key="depart_delay", - icon="mdi:airplane-takeoff", is_on_fn=lambda airport: airport.depart_delay.status, extra_state_attributes_fn=lambda airport: { "minimum": airport.depart_delay.minimum, @@ -65,7 +62,6 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", translation_key="arrive_delay", - icon="mdi:airplane-landing", is_on_fn=lambda airport: airport.arrive_delay.status, extra_state_attributes_fn=lambda airport: { "minimum": airport.arrive_delay.minimum, @@ -77,7 +73,6 @@ class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): FaaDelaysBinarySensorEntityDescription( key="CLOSURE", translation_key="closure", - icon="mdi:airplane:off", is_on_fn=lambda airport: airport.closure.status, extra_state_attributes_fn=lambda airport: { "begin": airport.closure.start, diff --git a/homeassistant/components/faa_delays/icons.json b/homeassistant/components/faa_delays/icons.json new file mode 100644 index 00000000000000..e5a795a99c48f9 --- /dev/null +++ b/homeassistant/components/faa_delays/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "binary_sensor": { + "ground_delay": { + "default": "mdi:airport" + }, + "ground_stop": { + "default": "mdi:airport" + }, + "depart_delay": { + "default": "mdi:airplane-takeoff" + }, + "arrive_delay": { + "default": "mdi:airplane-landing" + }, + "closure": { + "default": "mdi:airplane-off" + } + } + } +} diff --git a/homeassistant/components/fan/icons.json b/homeassistant/components/fan/icons.json index ebc4988e87f0a9..f962d1e7c1a401 100644 --- a/homeassistant/components/fan/icons.json +++ b/homeassistant/components/fan/icons.json @@ -11,6 +11,10 @@ "state": { "reverse": "mdi:rotate-left" } + }, + "preset_mode": { + "default": "mdi:circle-medium", + "state": {} } } } diff --git a/homeassistant/components/fastdotcom/icons.json b/homeassistant/components/fastdotcom/icons.json new file mode 100644 index 00000000000000..5c61065d25708b --- /dev/null +++ b/homeassistant/components/fastdotcom/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "download": { + "default": "mdi:speedometer" + } + } + }, + "services": { + "speedtest": "mdi:speedometer" + } +} diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 2ca0b2d91686b3..a213898562b928 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -36,7 +36,6 @@ class SpeedtestSensor( _attr_device_class = SensorDeviceClass.DATA_RATE _attr_native_unit_of_measurement = UnitOfDataRate.MEGABITS_PER_SECOND _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:speedometer" _attr_should_poll = False _attr_has_entity_name = True diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index 42b8a5c0446d66..cb64acdea146f7 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -143,10 +143,7 @@ def __init__(self, fibaro_device: DeviceModel) -> None: for device in siblings: # Detecting temperature device, one strong and one weak way of # doing so, so we prefer the hard evidence, if there is such. - if device.type == "com.fibaro.temperatureSensor": - self._temp_sensor_device = FibaroDevice(device) - tempunit = device.unit - elif ( + if device.type == "com.fibaro.temperatureSensor" or ( self._temp_sensor_device is None and device.has_unit and (device.value.has_value or device.has_heating_thermostat_setpoint) diff --git a/homeassistant/components/filesize/icons.json b/homeassistant/components/filesize/icons.json new file mode 100644 index 00000000000000..158295898532d0 --- /dev/null +++ b/homeassistant/components/filesize/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "size": { + "default": "mdi:file" + }, + "size_bytes": { + "default": "mdi:file" + }, + "last_updated": { + "default": "mdi:file" + } + } + } +} diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index c8e5dae5892c3c..7d41989cfca313 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -23,13 +23,10 @@ _LOGGER = logging.getLogger(__name__) -ICON = "mdi:file" - SENSOR_TYPES = ( SensorEntityDescription( key="file", translation_key="size", - icon=ICON, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, @@ -38,7 +35,6 @@ key="bytes", translation_key="size_bytes", entity_registry_enabled_default=False, - icon=ICON, native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, @@ -48,7 +44,6 @@ key="last_updated", translation_key="last_updated", entity_registry_enabled_default=False, - icon=ICON, device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py index 27f2c4a4526a69..d2f4e2e11f2fe1 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -49,23 +49,10 @@ def __init__( self._client = client self._attr_unique_id = f"{entry.unique_id}_Duty" - self._state: bool | None = None - - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - if self._state: - return "mdi:calendar-check" - - return "mdi:calendar-remove" - @property def is_on(self) -> bool | None: """Return the state of the binary sensor.""" - - self._state = self._client.on_duty - - return self._state + return self._client.on_duty @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/fireservicerota/icons.json b/homeassistant/components/fireservicerota/icons.json new file mode 100644 index 00000000000000..8de4c444ca8df9 --- /dev/null +++ b/homeassistant/components/fireservicerota/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "binary_sensor": { + "duty": { + "default": "mdi:calendar-remove", + "state": { + "on": "mdi:calendar-check" + } + } + } + } +} diff --git a/homeassistant/components/fivem/const.py b/homeassistant/components/fivem/const.py index 1676dc9f2b3717..28ce61981c68ee 100644 --- a/homeassistant/components/fivem/const.py +++ b/homeassistant/components/fivem/const.py @@ -5,10 +5,6 @@ DOMAIN = "fivem" -ICON_PLAYERS_MAX = "mdi:account-multiple" -ICON_PLAYERS_ONLINE = "mdi:account-multiple" -ICON_RESOURCES = "mdi:playlist-check" - MANUFACTURER = "Cfx.re" NAME_PLAYERS_MAX = "Players Max" diff --git a/homeassistant/components/fivem/icons.json b/homeassistant/components/fivem/icons.json new file mode 100644 index 00000000000000..d5d019348d91a9 --- /dev/null +++ b/homeassistant/components/fivem/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "max_players": { + "default": "mdi:account-multiple" + }, + "online_players": { + "default": "mdi:account-multiple" + }, + "resources": { + "default": "mdi:playlist-check" + } + } + } +} diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 967a1392fe545c..c39f67c5503c40 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -11,9 +11,6 @@ ATTR_PLAYERS_LIST, ATTR_RESOURCES_LIST, DOMAIN, - ICON_PLAYERS_MAX, - ICON_PLAYERS_ONLINE, - ICON_RESOURCES, NAME_PLAYERS_MAX, NAME_PLAYERS_ONLINE, NAME_RESOURCES, @@ -33,20 +30,17 @@ class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescripti FiveMSensorEntityDescription( key=NAME_PLAYERS_MAX, translation_key="max_players", - icon=ICON_PLAYERS_MAX, native_unit_of_measurement=UNIT_PLAYERS_MAX, ), FiveMSensorEntityDescription( key=NAME_PLAYERS_ONLINE, translation_key="online_players", - icon=ICON_PLAYERS_ONLINE, native_unit_of_measurement=UNIT_PLAYERS_ONLINE, extra_attrs=[ATTR_PLAYERS_LIST], ), FiveMSensorEntityDescription( key=NAME_RESOURCES, translation_key="resources", - icon=ICON_RESOURCES, native_unit_of_measurement=UNIT_RESOURCES, extra_attrs=[ATTR_RESOURCES_LIST], ), diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py index ba7134d7e50722..5732fb3822ca2e 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -11,6 +11,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py index 0d8a381a014c0d..84785720fb2fc6 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -15,6 +15,7 @@ PRESET_HOME, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -37,12 +38,12 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Flexit Nordic unit.""" coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_devices([FlexitClimateEntity(coordinator)]) + async_add_entities([FlexitClimateEntity(coordinator)]) class FlexitClimateEntity(FlexitEntity, ClimateEntity): @@ -83,6 +84,13 @@ async def async_update(self) -> None: """Refresh unit state.""" await self.device.update() + @property + def hvac_action(self) -> HVACAction | None: + """Return current HVAC action.""" + if self.device.electric_heater: + return HVACAction.HEATING + return HVACAction.FAN + @property def current_temperature(self) -> float: """Return the current temperature.""" diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json new file mode 100644 index 00000000000000..7ce8b116a27903 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/icons.json @@ -0,0 +1,44 @@ +{ + "entity": { + "number": { + "away_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "away_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "cooker_hood_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "cooker_hood_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "fireplace_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "fireplace_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "high_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "high_supply_fan_setpoint": { + "default": "mdi:fan-plus" + }, + "home_extract_fan_setpoint": { + "default": "mdi:fan-minus" + }, + "home_supply_fan_setpoint": { + "default": "mdi:fan-plus" + } + }, + "switch": { + "electric_heater": { + "default": "mdi:radiator", + "state": { + "off": "mdi:radiator-off" + } + } + } + } +} diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py new file mode 100644 index 00000000000000..2731d5e8b092a7 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/number.py @@ -0,0 +1,204 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FlexitCoordinator +from .const import DOMAIN +from .entity import FlexitEntity + + +@dataclass(kw_only=True, frozen=True) +class FlexitNumberEntityDescription(NumberEntityDescription): + """Describes a Flexit number entity.""" + + native_value_fn: Callable[[FlexitBACnet], float] + set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]] + + +NUMBERS: tuple[FlexitNumberEntityDescription, ...] = ( + FlexitNumberEntityDescription( + key="away_extract_fan_setpoint", + translation_key="away_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_away, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_away, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="away_supply_fan_setpoint", + translation_key="away_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_away, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_away, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="cooker_hood_extract_fan_setpoint", + translation_key="cooker_hood_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_cooker, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_cooker, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="cooker_hood_supply_fan_setpoint", + translation_key="cooker_hood_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_cooker, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_cooker, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="fireplace_extract_fan_setpoint", + translation_key="fireplace_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_fire, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_fire, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="fireplace_supply_fan_setpoint", + translation_key="fireplace_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_fire, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_fire, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="high_extract_fan_setpoint", + translation_key="high_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_high, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_high, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="high_supply_fan_setpoint", + translation_key="high_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_high, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_high, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="home_extract_fan_setpoint", + translation_key="home_extract_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_extract_air_home, + set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_home, + native_unit_of_measurement=PERCENTAGE, + ), + FlexitNumberEntityDescription( + key="home_supply_fan_setpoint", + translation_key="home_supply_fan_setpoint", + device_class=NumberDeviceClass.POWER_FACTOR, + native_min_value=0, + native_max_value=100, + native_step=1, + mode=NumberMode.SLIDER, + native_value_fn=lambda device: device.fan_setpoint_supply_air_home, + set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_home, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Flexit (bacnet) number from a config entry.""" + coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + FlexitNumber(coordinator, description) for description in NUMBERS + ) + + +class FlexitNumber(FlexitEntity, NumberEntity): + """Representation of a Flexit Number.""" + + entity_description: FlexitNumberEntityDescription + + def __init__( + self, + coordinator: FlexitCoordinator, + entity_description: FlexitNumberEntityDescription, + ) -> None: + """Initialize Flexit (bacnet) number.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.device.serial_number}-{entity_description.key}" + ) + + @property + def native_value(self) -> float: + """Return the state of the number.""" + return self.entity_description.native_value_fn(self.coordinator.device) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + set_native_value_fn = self.entity_description.set_native_value_fn( + self.coordinator.device + ) + try: + await set_native_value_fn(int(value)) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + finally: + await self.coordinator.async_refresh() diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json index d9efd1fc41165d..7f763674d00288 100644 --- a/homeassistant/components/flexit_bacnet/strings.json +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -22,6 +22,38 @@ "name": "Air filter polluted" } }, + "number": { + "away_extract_fan_setpoint": { + "name": "Away extract fan setpoint" + }, + "away_supply_fan_setpoint": { + "name": "Away supply fan setpoint" + }, + "cooker_hood_extract_fan_setpoint": { + "name": "Cooker hood extract fan setpoint" + }, + "cooker_hood_supply_fan_setpoint": { + "name": "Cooker hood supply fan setpoint" + }, + "fireplace_extract_fan_setpoint": { + "name": "Fireplace extract fan setpoint" + }, + "fireplace_supply_fan_setpoint": { + "name": "Fireplace supply fan setpoint" + }, + "high_extract_fan_setpoint": { + "name": "High extract fan setpoint" + }, + "high_supply_fan_setpoint": { + "name": "High supply fan setpoint" + }, + "home_extract_fan_setpoint": { + "name": "Home extract fan setpoint" + }, + "home_supply_fan_setpoint": { + "name": "Home supply fan setpoint" + } + }, "sensor": { "outside_air_temperature": { "name": "Outside air temperature" diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py index b3751c90f7d0bb..0a7785eaa38aeb 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -35,7 +35,6 @@ class FlexitSwitchEntityDescription(SwitchEntityDescription): FlexitSwitchEntityDescription( key="electric_heater", translation_key="electric_heater", - icon="mdi:radiator", is_on_fn=lambda data: data.electric_heater, turn_on_fn=lambda data: data.enable_electric_heater(), turn_off_fn=lambda data: data.disable_electric_heater(), diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py index 557d0492320d9f..842706172f1944 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -46,7 +46,7 @@ async def _validate_input(self, user_input): try: async with asyncio.timeout(60): token = await auth.async_get_access_token() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise CannotConnect() from err except AuthException as err: raise InvalidAuth() from err diff --git a/homeassistant/components/flo/icons.json b/homeassistant/components/flo/icons.json new file mode 100644 index 00000000000000..3164781c1b4d0f --- /dev/null +++ b/homeassistant/components/flo/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "switch": { + "shutoff_valve": { + "default": "mdi:valve-closed", + "state": { + "on": "mdi:valve-open" + } + } + } + }, + "services": { + "set_sleep_mode": "mdi:sleep", + "set_away_mode": "mdi:home-off", + "set_home_mode": "mdi:home", + "run_health_test": "mdi:heart-flash" + } +} diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py index b2a0afdcb13e46..476898c8ef3f61 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -12,6 +12,7 @@ UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,9 +21,6 @@ from .device import FloDeviceDataUpdateCoordinator from .entity import FloEntity -WATER_ICON = "mdi:water" -GAUGE_ICON = "mdi:gauge" - async def async_setup_entry( hass: HomeAssistant, @@ -59,7 +57,6 @@ async def async_setup_entry( class FloDailyUsageSensor(FloEntity, SensorEntity): """Monitors the daily water usage.""" - _attr_icon = WATER_ICON _attr_native_unit_of_measurement = UnitOfVolume.GALLONS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_device_class = SensorDeviceClass.WATER @@ -97,9 +94,9 @@ def native_value(self) -> str | None: class FloCurrentFlowRateSensor(FloEntity, SensorEntity): """Monitors the current water flow rate.""" - _attr_icon = GAUGE_ICON - _attr_native_unit_of_measurement = "gpm" + _attr_native_unit_of_measurement = UnitOfVolumeFlowRate.GALLONS_PER_MINUTE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _attr_device_class = SensorDeviceClass.VOLUME_FLOW_RATE _attr_translation_key = "current_flow_rate" def __init__(self, device): diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 00e5e57498fd76..62a57c463e20ad 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -75,13 +75,6 @@ def __init__(self, device: FloDeviceDataUpdateCoordinator) -> None: super().__init__("shutoff_valve", device) self._attr_is_on = device.last_known_valve_state == "open" - @property - def icon(self): - """Return the icon to use for the valve.""" - if self.is_on: - return "mdi:valve-open" - return "mdi:valve-closed" - async def async_turn_on(self, **kwargs: Any) -> None: """Open the valve.""" await self._device.api_client.device.open_valve(self._device.id) diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py index 3fdd54dd40d9ed..c5926e3158ef5f 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -58,5 +58,5 @@ async def async_send_message(self, message, **kwargs): response.status, result, ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index fd6fcc5f4b9436..a31fecf305e4b3 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -59,14 +59,12 @@ class FlumeBinarySensorEntityDescription( translation_key="leak", entity_category=EntityCategory.DIAGNOSTIC, event_rule=NOTIFICATION_LEAK_DETECTED, - icon="mdi:pipe-leak", ), FlumeBinarySensorEntityDescription( key="flow", translation_key="flow", entity_category=EntityCategory.DIAGNOSTIC, event_rule=NOTIFICATION_HIGH_FLOW, - icon="mdi:waves", ), FlumeBinarySensorEntityDescription( key="low_battery", diff --git a/homeassistant/components/flume/icons.json b/homeassistant/components/flume/icons.json new file mode 100644 index 00000000000000..631c0645ed3f7e --- /dev/null +++ b/homeassistant/components/flume/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "binary_sensor": { + "leak": { + "default": "mdi:pipe-leak" + }, + "flow": { + "default": "mdi:waves" + } + } + }, + "services": { + "list_notifications": "mdi:bell" + } +} diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 100d63d8bf7fa7..2d9dddd3684d47 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -11,7 +11,7 @@ from flux_led.scanner import FluxLEDDiscovery from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -37,7 +37,6 @@ FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, SIGNAL_STATE_UPDATED, - STARTUP_SCAN_TIMEOUT, ) from .coordinator import FluxLedUpdateCoordinator from .discovery import ( @@ -89,24 +88,21 @@ def async_wifi_bulb_for_host( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the flux_led component.""" domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[FLUX_LED_DISCOVERY] = await async_discover_devices( - hass, STARTUP_SCAN_TIMEOUT - ) + domain_data[FLUX_LED_DISCOVERY] = [] @callback def _async_start_background_discovery(*_: Any) -> None: """Run discovery in the background.""" - hass.async_create_background_task(_async_discovery(), "flux_led-discovery") + hass.async_create_background_task( + _async_discovery(), "flux_led-discovery", eager_start=True + ) async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) - async_trigger_discovery(hass, domain_data[FLUX_LED_DISCOVERY]) - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, _async_start_background_discovery - ) + _async_start_background_discovery() async_track_time_interval( hass, _async_start_background_discovery, diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 9094006c791e8e..d50e6a08b5a439 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -125,9 +125,7 @@ async def _async_set_discovered_mac( config_entries.ConfigEntryState.NOT_LOADED, ) ) or entry.state == config_entries.ConfigEntryState.SETUP_RETRY: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) else: async_dispatcher_send( self.hass, diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index 8b42f5f2e0dddf..08e1d274ea752a 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -1,6 +1,5 @@ """Constants of the FluxLed/MagicHome Integration.""" -import asyncio import socket from typing import Final @@ -38,7 +37,7 @@ FLUX_LED_DISCOVERY: Final = "flux_led_discovery" FLUX_LED_EXCEPTIONS: Final = ( - asyncio.TimeoutError, + TimeoutError, socket.error, RuntimeError, BrokenPipeError, diff --git a/homeassistant/components/flux_led/util.py b/homeassistant/components/flux_led/util.py index 7aa2d91de4e67b..8db12cb6e32343 100644 --- a/homeassistant/components/flux_led/util.py +++ b/homeassistant/components/flux_led/util.py @@ -12,6 +12,8 @@ def _hass_color_modes(device: AIOWifiLedBulb) -> set[str]: color_modes = device.color_modes + if not color_modes: + return {ColorMode.ONOFF} return {_flux_color_mode_to_hass(mode, color_modes) for mode in color_modes} diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index a865dd33053892..0af1206dbd3f99 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -1,7 +1,6 @@ """Support for the Foobot indoor air quality monitor.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging from typing import Any @@ -118,7 +117,7 @@ async def async_setup_platform( ) except ( aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, FoobotClient.TooManyRequests, FoobotClient.InternalError, ) as err: @@ -175,7 +174,7 @@ async def async_update(self) -> bool: ) except ( aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, self._client.TooManyRequests, self._client.InternalError, ): diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index c6d4236c2192b5..1d28aad6a92aec 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -28,10 +28,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0), } - entry.version = 2 - hass.config_entries.async_update_entry( - entry, data=entry.data, options=new_options + entry, data=entry.data, options=new_options, version=2 ) return True diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 48c2be07c76431..df12de944ae5a6 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -668,7 +668,7 @@ async def _pause_and_wait_for_callback(self): try: async with asyncio.timeout(CALLBACK_TIMEOUT): await self._paused_event.wait() # wait for paused - except asyncio.TimeoutError: + except TimeoutError: self._pause_requested = False self._paused_event.clear() @@ -764,7 +764,7 @@ async def _async_announce(self, media_id: str) -> None: async with asyncio.timeout(TTS_TIMEOUT): await self._tts_playing_event.wait() # we have started TTS, now wait for completion - except asyncio.TimeoutError: + except TimeoutError: self._tts_requested = False _LOGGER.warning("TTS request timed out") await asyncio.sleep( diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 057ef4dbe8cdb0..aed3ed637ae0c3 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -66,8 +66,6 @@ def update_unique_id(entry): await async_migrate_entries(hass, entry.entry_id, update_unique_id) - entry.unique_id = None - # Get RTSP port from the camera or use the fallback one and store it in data camera = FoscamCamera( entry.data[CONF_HOST], @@ -85,12 +83,12 @@ def update_unique_id(entry): rtsp_port = response.get("rtspPort") or response.get("mediaPort") hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_RTSP_PORT: rtsp_port} + entry, + data={**entry.data, CONF_RTSP_PORT: rtsp_port}, + version=2, + unique_id=None, ) - # Change entry version - entry.version = 2 - LOGGER.info("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/foscam/icons.json b/homeassistant/components/foscam/icons.json new file mode 100644 index 00000000000000..0c7dba9a4dfb09 --- /dev/null +++ b/homeassistant/components/foscam/icons.json @@ -0,0 +1,6 @@ +{ + "services": { + "ptz": "mdi:pan", + "ptz_preset": "mdi:target-variant" + } +} diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index da4e9f53af4e7c..6f256f99854290 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", "loggers": ["libpyfoscam"], - "requirements": ["libpyfoscam==1.0"] + "requirements": ["libpyfoscam==1.2.2"] } diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index e65856e03f4a3d..feb1fb9fed9720 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -96,7 +96,7 @@ async def _update_freedns(hass, session, url, auth_token): except aiohttp.ClientError: _LOGGER.warning("Can't connect to FreeDNS API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from FreeDNS API at %s", url) return False diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 00e9f406ed4cb7..f703fadb4b85ce 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -68,7 +68,7 @@ async def async_setup_entry( if description.is_suitable(connection_info) ] - async_add_entities(entities, True) + async_add_entities(entities) class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity): diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 5b4a3f5a20cd5c..de34056b0d7b6d 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -59,7 +59,6 @@ class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixi FritzButtonDescription( key="cleanup", translation_key="cleanup", - icon="mdi:broom", entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(), ), diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index c9acd60b23c6e8..3d287b57384f34 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -291,7 +291,7 @@ def setup(self) -> None: self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services - def register_entity_updates( + async def async_register_entity_updates( self, key: str, update_fn: Callable[[FritzStatus, StateType], Any] ) -> Callable[[], None]: """Register an entity to be updated by coordinator.""" @@ -305,6 +305,12 @@ def unregister_entity_updates() -> None: if key not in self._entity_update_functions: _LOGGER.debug("register entity %s for updates", key) self._entity_update_functions[key] = update_fn + if self.fritz_status: + self.data["entity_states"][ + key + ] = await self.hass.async_add_executor_job( + update_fn, self.fritz_status, self.data["entity_states"].get(key) + ) return unregister_entity_updates async def _async_update_data(self) -> UpdateCoordinatorDataType: @@ -1121,16 +1127,20 @@ def __init__( ) -> None: """Init device info class.""" super().__init__(avm_wrapper) - if description.value_fn is not None: - 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}" + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + if self.entity_description.value_fn is not None: + self.async_on_remove( + await self.coordinator.async_register_entity_updates( + self.entity_description.key, self.entity_description.value_fn + ) + ) + @property def device_info(self) -> DeviceInfo: """Return the device information.""" diff --git a/homeassistant/components/fritz/icons.json b/homeassistant/components/fritz/icons.json new file mode 100644 index 00000000000000..d2154dc7232f12 --- /dev/null +++ b/homeassistant/components/fritz/icons.json @@ -0,0 +1,59 @@ +{ + "entity": { + "button": { + "cleanup": { + "default": "mdi:broom" + } + }, + "sensor": { + "external_ip": { + "default": "mdi:earth" + }, + "external_ipv6": { + "default": "mdi:earth" + }, + "kb_s_sent": { + "default": "mdi:upload" + }, + "kb_s_received": { + "default": "mdi:download" + }, + "max_kb_s_sent": { + "default": "mdi:upload" + }, + "max_kb_s_received": { + "default": "mdi:download" + }, + "gb_sent": { + "default": "mdi:upload" + }, + "gb_received": { + "default": "mdi:download" + }, + "link_kb_s_sent": { + "default": "mdi:upload" + }, + "link_kb_s_received": { + "default": "mdi:download" + }, + "link_noise_margin_sent": { + "default": "mdi:upload" + }, + "link_noise_margin_received": { + "default": "mdi:download" + }, + "link_attenuation_sent": { + "default": "mdi:upload" + }, + "link_attenuation_received": { + "default": "mdi:download" + } + } + }, + "services": { + "reconnect": "mdi:connection", + "reboot": "mdi:refresh", + "cleanup": "mdi:broom", + "set_guest_wifi_password": "mdi:form-textbox-password" + } +} diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 53a299cd576745..7fcc4944ec5240 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -153,13 +153,11 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti FritzSensorEntityDescription( key="external_ip", translation_key="external_ip", - icon="mdi:earth", value_fn=_retrieve_external_ip_state, ), FritzSensorEntityDescription( key="external_ipv6", translation_key="external_ipv6", - icon="mdi:earth", value_fn=_retrieve_external_ipv6_state, is_suitable=lambda info: info.ipv6_active, ), @@ -184,7 +182,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", value_fn=_retrieve_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -193,7 +190,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", value_fn=_retrieve_kb_s_received_state, ), FritzSensorEntityDescription( @@ -201,7 +197,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti translation_key="max_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_sent_state, ), @@ -210,7 +205,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti translation_key="max_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_max_kb_s_received_state, ), @@ -220,7 +214,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:upload", value_fn=_retrieve_gb_sent_state, ), FritzSensorEntityDescription( @@ -229,7 +222,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:download", value_fn=_retrieve_gb_received_state, ), FritzSensorEntityDescription( @@ -237,7 +229,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti translation_key="link_kb_s_sent", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:upload", value_fn=_retrieve_link_kb_s_sent_state, ), FritzSensorEntityDescription( @@ -245,14 +236,12 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti translation_key="link_kb_s_received", native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, - icon="mdi:download", value_fn=_retrieve_link_kb_s_received_state, ), FritzSensorEntityDescription( key="link_noise_margin_sent", translation_key="link_noise_margin_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - icon="mdi:upload", value_fn=_retrieve_link_noise_margin_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -260,7 +249,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti key="link_noise_margin_received", translation_key="link_noise_margin_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - icon="mdi:download", value_fn=_retrieve_link_noise_margin_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -268,7 +256,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti key="link_attenuation_sent", translation_key="link_attenuation_sent", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - icon="mdi:upload", value_fn=_retrieve_link_attenuation_sent_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -276,7 +263,6 @@ class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescripti key="link_attenuation_received", translation_key="link_attenuation_received", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - icon="mdi:download", value_fn=_retrieve_link_attenuation_received_state, is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION, ), @@ -298,7 +284,7 @@ async def async_setup_entry( if description.is_suitable(connection_info) ] - async_add_entities(entities, True) + async_add_entities(entities) class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity): diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 9bfb1a6a7a01ff..bcfa945e1df5e8 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -50,6 +50,27 @@ "dialing": "Dialing", "talking": "Talking", "idle": "[%key:common::state::idle%]" + }, + "state_attributes": { + "prefixes": { "name": "Prefixes" }, + "type": { + "name": "Type", + "state": { + "incoming": "Incoming", + "outgoing": "Outgoing" + } + }, + "from": { "name": "Caller number" }, + "to": { "name": "Number called" }, + "device": { "name": "[%key:common::config_flow::data::device%]" }, + "initiated": { "name": "Initiated" }, + "from_name": { "name": "Caller name" }, + "to_name": { "name": "Called name" }, + "with": { "name": "With number" }, + "accepted": { "name": "Accepted" }, + "with_name": { "name": "With name" }, + "duration": { "name": "Duration" }, + "closed": { "name": "Closed" } } } } diff --git a/homeassistant/components/fronius/icons.json b/homeassistant/components/fronius/icons.json new file mode 100644 index 00000000000000..a84140617dd48a --- /dev/null +++ b/homeassistant/components/fronius/icons.json @@ -0,0 +1,45 @@ +{ + "entity": { + "sensor": { + "current_dc": { + "default": "mdi:current-dc" + }, + "current_dc_2": { + "default": "mdi:current-dc" + }, + "voltage_dc": { + "default": "mdi:current-dc" + }, + "voltage_dc_2": { + "default": "mdi:current-dc" + }, + "co2_factor": { + "default": "mdi:molecule-co2" + }, + "cash_factor": { + "default": "mdi:cash-plus" + }, + "delivery_factor": { + "default": "mdi:cash-minus" + }, + "energy_reactive_ac_consumed": { + "default": "mdi:lightning-bolt-outline" + }, + "energy_reactive_ac_produced": { + "default": "mdi:lightning-bolt-outline" + }, + "relative_autonomy": { + "default": "mdi:home-circle-outline" + }, + "relative_self_consumption": { + "default": "mdi:solar-power" + }, + "voltage_dc_maximum_cell": { + "default": "mdi:current-dc" + }, + "voltage_dc_minimum_cell": { + "default": "mdi:current-dc" + } + } + } +} diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index 1ec62c54b6ceb3..c2f635119aa9b4 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["PyFronius==0.7.2"] + "requirements": ["PyFronius==0.7.3"] } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 93c13c8e5794c7..2fa4e4fd16070d 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -157,7 +157,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), FroniusSensorEntityDescription( key="current_dc_2", @@ -165,7 +164,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), FroniusSensorEntityDescription( key="power_ac", @@ -188,7 +186,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), FroniusSensorEntityDescription( key="voltage_dc_2", @@ -196,7 +193,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), # device status entities FroniusSensorEntityDescription( @@ -236,17 +232,14 @@ class FroniusSensorEntityDescription(SensorEntityDescription): FroniusSensorEntityDescription( key="co2_factor", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:molecule-co2", ), FroniusSensorEntityDescription( key="cash_factor", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:cash-plus", ), FroniusSensorEntityDescription( key="delivery_factor", state_class=SensorStateClass.MEASUREMENT, - icon="mdi:cash-minus", ), ] @@ -276,7 +269,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): key="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, invalid_when_falsy=True, ), @@ -284,7 +276,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): key="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, invalid_when_falsy=True, ), @@ -342,7 +333,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -350,7 +340,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -358,7 +347,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -366,7 +354,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -397,7 +384,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -405,7 +391,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -413,7 +398,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -421,7 +405,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): 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, ), FroniusSensorEntityDescription( @@ -593,14 +576,12 @@ class FroniusSensorEntityDescription(SensorEntityDescription): default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:home-circle-outline", ), FroniusSensorEntityDescription( key="relative_self_consumption", default_value=0, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:solar-power", ), ] @@ -620,21 +601,18 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), FroniusSensorEntityDescription( key="voltage_dc", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", ), FroniusSensorEntityDescription( key="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, ), FroniusSensorEntityDescription( @@ -642,7 +620,6 @@ class FroniusSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:current-dc", entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 09419f2d3bd16e..48d5bcb0b057da 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -644,9 +644,11 @@ class ManifestJSONView(HomeAssistantView): @callback def get(self, request: web.Request) -> web.Response: """Return the manifest.json.""" - return web.Response( + response = web.Response( text=MANIFEST_JSON.json, content_type="application/manifest+json" ) + response.enable_compression() + return response @websocket_api.websocket_command( diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 21f4df7956800c..cea376fa8ffb35 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==20240207.1"] + "requirements": ["home-assistant-frontend==20240306.0"] } diff --git a/homeassistant/components/fully_kiosk/config_flow.py b/homeassistant/components/fully_kiosk/config_flow.py index 4f9dadd6901b25..00eb1dd71016e2 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -58,7 +58,7 @@ async def _create_entry( except ( ClientConnectorError, FullyKioskError, - asyncio.TimeoutError, + TimeoutError, ) as error: LOGGER.debug(error.args, exc_info=True) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/fully_kiosk/icons.json b/homeassistant/components/fully_kiosk/icons.json new file mode 100644 index 00000000000000..760698f7ac840f --- /dev/null +++ b/homeassistant/components/fully_kiosk/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "load_url": "mdi:link", + "set_config": "mdi:cog", + "start_application": "mdi:rocket-launch" + } +} diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py index 0984d6a220ff7e..8e6d2fad533bd5 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -12,7 +12,7 @@ async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import AUDIOMANAGER_STREAM_MUSIC, DOMAIN, MEDIA_SUPPORT_FULLYKIOSK @@ -82,3 +82,13 @@ async def async_browse_media( media_content_id, content_filter=lambda item: item.media_content_type.startswith("audio/"), ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_state = ( + MediaPlayerState.PLAYING + if "soundUrlPlaying" in self.coordinator.data + else MediaPlayerState.IDLE + ) + self.async_write_ha_state() diff --git a/homeassistant/components/garages_amsterdam/icons.json b/homeassistant/components/garages_amsterdam/icons.json new file mode 100644 index 00000000000000..156ee85f157169 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "sensor": { + "free_space_short": { + "default": "mdi:car" + }, + "free_space_long": { + "default": "mdi:car" + }, + "short_capacity": { + "default": "mdi:car" + }, + "long_capacity": { + "default": "mdi:car" + } + } + } +} diff --git a/homeassistant/components/garages_amsterdam/manifest.json b/homeassistant/components/garages_amsterdam/manifest.json index 3ce96152337cd6..ebda913abbb328 100644 --- a/homeassistant/components/garages_amsterdam/manifest.json +++ b/homeassistant/components/garages_amsterdam/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/garages_amsterdam", "iot_class": "cloud_polling", - "requirements": ["odp-amsterdam==6.0.0"] + "requirements": ["odp-amsterdam==6.0.1"] } diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py index a79ddc27379ac8..48a3746a762415 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -11,10 +11,10 @@ from .entity import GaragesAmsterdamEntity SENSORS = { - "free_space_short": "mdi:car", - "free_space_long": "mdi:car", - "short_capacity": "mdi:car", - "long_capacity": "mdi:car", + "free_space_short", + "free_space_long", + "short_capacity", + "long_capacity", } @@ -50,7 +50,6 @@ def __init__( """Initialize garages amsterdam sensor.""" super().__init__(coordinator, garage_name, info_type) self._attr_translation_key = info_type - self._attr_icon = SENSORS[info_type] @property def available(self) -> bool: diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index df41b0a1c4324f..99c8fa69acfb31 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -1,7 +1,6 @@ """The Gardena Bluetooth integration.""" from __future__ import annotations -import asyncio import logging from bleak.backends.device import BLEDevice @@ -60,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) uuids = await client.get_all_characteristics_uuid() await client.update_timestamp(dt_util.now()) - except (asyncio.TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: + except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: await client.disconnect() raise ConfigEntryNotReady( f"Unable to connect to device {address} due to {exception}" diff --git a/homeassistant/components/gaviota/__init__.py b/homeassistant/components/gaviota/__init__.py new file mode 100644 index 00000000000000..00ea9749899987 --- /dev/null +++ b/homeassistant/components/gaviota/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Gaviota.""" diff --git a/homeassistant/components/gdacs/icons.json b/homeassistant/components/gdacs/icons.json new file mode 100644 index 00000000000000..1a99aa9fb7b2e4 --- /dev/null +++ b/homeassistant/components/gdacs/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "alerts": { + "default": "mdi:alert" + } + } + } +} diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 8039d5274ed7ee..f660c8f73c82c7 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -17,7 +17,7 @@ from homeassistant.util import dt as dt_util from . import GdacsFeedEntityManager -from .const import DEFAULT_ICON, DOMAIN, FEED +from .const import DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -48,10 +48,10 @@ class GdacsSensor(SensorEntity): """Status sensor for the GDACS integration.""" _attr_should_poll = False - _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT _attr_has_entity_name = True _attr_name = None + _attr_translation_key = "alerts" def __init__( self, config_entry: ConfigEntry, manager: GdacsFeedEntityManager diff --git a/homeassistant/components/generic/icons.json b/homeassistant/components/generic/icons.json new file mode 100644 index 00000000000000..a03163179cb5bd --- /dev/null +++ b/homeassistant/components/generic/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "reload": "mdi:reload" + } +} diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 095b46245cf60c..d69a8a968c7f9e 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -396,9 +396,12 @@ async def _async_update_humidity(self, humidity: str) -> None: try: self._cur_humidity = float(humidity) except ValueError as ex: - _LOGGER.warning("Unable to update from sensor: %s", ex) + if self._active: + _LOGGER.warning("Unable to update from sensor: %s", ex) + self._active = False + else: + _LOGGER.debug("Unable to update from sensor: %s", ex) self._cur_humidity = None - self._active = False if self._is_device_active: await self._async_device_turn_off() diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 3a964204b70226..64fde0dfd268f1 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -15,6 +15,7 @@ PRESET_ACTIVITY, PRESET_AWAY, PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, PRESET_NONE, PRESET_SLEEP, @@ -86,6 +87,7 @@ for p in ( PRESET_AWAY, PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, PRESET_SLEEP, PRESET_ACTIVITY, diff --git a/homeassistant/components/geocaching/icons.json b/homeassistant/components/geocaching/icons.json new file mode 100644 index 00000000000000..7dce199672b12b --- /dev/null +++ b/homeassistant/components/geocaching/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "find_count": { + "default": "mdi:notebook-edit-outline" + }, + "hide_count": { + "default": "mdi:eye-off-outline" + }, + "favorite_points": { + "default": "mdi:heart-outline" + }, + "souvenir_count": { + "default": "mdi:license" + }, + "awarded_favorite_points": { + "default": "mdi:heart" + } + } + } +} diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index dd324492d73393..91f7addae440ef 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -36,14 +36,12 @@ class GeocachingSensorEntityDescription( GeocachingSensorEntityDescription( key="find_count", translation_key="find_count", - icon="mdi:notebook-edit-outline", native_unit_of_measurement="caches", value_fn=lambda status: status.user.find_count, ), GeocachingSensorEntityDescription( key="hide_count", translation_key="hide_count", - icon="mdi:eye-off-outline", native_unit_of_measurement="caches", entity_registry_visible_default=False, value_fn=lambda status: status.user.hide_count, @@ -51,7 +49,6 @@ class GeocachingSensorEntityDescription( GeocachingSensorEntityDescription( key="favorite_points", translation_key="favorite_points", - icon="mdi:heart-outline", native_unit_of_measurement="points", entity_registry_visible_default=False, value_fn=lambda status: status.user.favorite_points, @@ -59,14 +56,12 @@ class GeocachingSensorEntityDescription( GeocachingSensorEntityDescription( key="souvenir_count", translation_key="souvenir_count", - icon="mdi:license", native_unit_of_measurement="souvenirs", value_fn=lambda status: status.user.souvenir_count, ), GeocachingSensorEntityDescription( key="awarded_favorite_points", translation_key="awarded_favorite_points", - icon="mdi:heart", native_unit_of_measurement="points", entity_registry_visible_default=False, value_fn=lambda status: status.user.awarded_favorite_points, diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index ffc34bd2b7804b..1595b7ad13150e 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -45,7 +45,7 @@ async def async_step_user( title=gios.station_name, data=user_input, ) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except NoStationError: errors[CONF_STATION_ID] = "wrong_station_id" diff --git a/homeassistant/components/gios/icons.json b/homeassistant/components/gios/icons.json new file mode 100644 index 00000000000000..e1d848e276b36a --- /dev/null +++ b/homeassistant/components/gios/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "aqi": { + "default": "mdi:air-filter" + }, + "c6h6": { + "default": "mdi:molecule" + }, + "co": { + "default": "mdi:molecule" + }, + "no2_index": { + "default": "mdi:molecule" + }, + "o3_index": { + "default": "mdi:molecule" + }, + "pm10_index": { + "default": "mdi:molecule" + }, + "pm25_index": { + "default": "mdi:molecule" + }, + "so2_index": { + "default": "mdi:molecule" + } + } + } +} diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 99c1775beef6c3..1b13430128fa4c 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -54,7 +54,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): GiosSensorEntityDescription( key=ATTR_AQI, 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", @@ -63,7 +62,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): key=ATTR_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", @@ -72,7 +70,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): key=ATTR_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", @@ -89,7 +86,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): 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", @@ -106,7 +102,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): 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", @@ -123,7 +118,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): 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", @@ -140,7 +134,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): 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", @@ -157,7 +150,6 @@ class GiosSensorEntityDescription(SensorEntityDescription): 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", diff --git a/homeassistant/components/glances/icons.json b/homeassistant/components/glances/icons.json new file mode 100644 index 00000000000000..6a8c2fa728c243 --- /dev/null +++ b/homeassistant/components/glances/icons.json @@ -0,0 +1,51 @@ +{ + "entity": { + "sensor": { + "disk_usage": { + "default": "mdi:harddisk" + }, + "disk_used": { + "default": "mdi:harddisk" + }, + "disk_free": { + "default": "mdi:harddisk" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_use": { + "default": "mdi:memory" + }, + "memory_free": { + "default": "mdi:memory" + }, + "swap_usage": { + "default": "mdi:memory" + }, + "swap_use": { + "default": "mdi:memory" + }, + "swap_free": { + "default": "mdi:memory" + }, + "fan_speed": { + "default": "mdi:fan" + }, + "container_active": { + "default": "mdi:docker" + }, + "container_cpu_usage": { + "default": "mdi:docker" + }, + "container_memory_used": { + "default": "mdi:docker" + }, + "raid_available": { + "default": "mdi:harddisk" + }, + "raid_used": { + "default": "mdi:harddisk" + } + } + } +} diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 2119e990e447da..f3718dc4c0e87a 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -47,7 +47,6 @@ class GlancesSensorEntityDescription( type="fs", translation_key="disk_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("fs", "disk_use"): GlancesSensorEntityDescription( @@ -56,7 +55,6 @@ class GlancesSensorEntityDescription( translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("fs", "disk_free"): GlancesSensorEntityDescription( @@ -65,7 +63,6 @@ class GlancesSensorEntityDescription( translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("mem", "memory_use_percent"): GlancesSensorEntityDescription( @@ -73,16 +70,14 @@ class GlancesSensorEntityDescription( type="mem", translation_key="memory_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("mem", "memory_use"): GlancesSensorEntityDescription( key="memory_use", type="mem", - translation_key="memory_used", + translation_key="memory_use", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("mem", "memory_free"): GlancesSensorEntityDescription( @@ -91,7 +86,6 @@ class GlancesSensorEntityDescription( translation_key="memory_free", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("memswap", "swap_use_percent"): GlancesSensorEntityDescription( @@ -99,16 +93,14 @@ class GlancesSensorEntityDescription( type="memswap", translation_key="swap_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("memswap", "swap_use"): GlancesSensorEntityDescription( key="swap_use", type="memswap", - translation_key="swap_used", + translation_key="swap_use", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("memswap", "swap_free"): GlancesSensorEntityDescription( @@ -117,7 +109,6 @@ class GlancesSensorEntityDescription( translation_key="swap_free", native_unit_of_measurement=UnitOfInformation.GIBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, ), ("load", "processor_load"): GlancesSensorEntityDescription( @@ -184,7 +175,6 @@ class GlancesSensorEntityDescription( type="sensors", translation_key="fan_speed", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, - icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, ), ("sensors", "battery"): GlancesSensorEntityDescription( @@ -193,14 +183,12 @@ class GlancesSensorEntityDescription( translation_key="charge", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, - icon="mdi:battery", state_class=SensorStateClass.MEASUREMENT, ), ("docker", "docker_active"): GlancesSensorEntityDescription( key="docker_active", type="docker", translation_key="container_active", - icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), ("docker", "docker_cpu_use"): GlancesSensorEntityDescription( @@ -208,7 +196,6 @@ class GlancesSensorEntityDescription( type="docker", translation_key="container_cpu_usage", native_unit_of_measurement=PERCENTAGE, - icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), ("docker", "docker_memory_use"): GlancesSensorEntityDescription( @@ -217,21 +204,18 @@ class GlancesSensorEntityDescription( translation_key="container_memory_used", native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, - icon="mdi:docker", state_class=SensorStateClass.MEASUREMENT, ), ("raid", "available"): GlancesSensorEntityDescription( key="available", type="raid", translation_key="raid_available", - icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), ("raid", "used"): GlancesSensorEntityDescription( key="used", type="raid", translation_key="raid_used", - icon="mdi:harddisk", state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/homeassistant/components/glances/strings.json b/homeassistant/components/glances/strings.json index 972106d352f735..b0b535ce8edf65 100644 --- a/homeassistant/components/glances/strings.json +++ b/homeassistant/components/glances/strings.json @@ -59,7 +59,7 @@ "swap_free": { "name": "Swap free" }, - "cpu_load": { + "processor_load": { "name": "CPU load" }, "process_running": { diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index f32cad5a488075..7a7000ba7804f0 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,6 +1,8 @@ """The Goal Zero Yeti integration.""" from __future__ import annotations +from typing import TYPE_CHECKING + from goalzero import Yeti, exceptions from homeassistant.config_entries import ConfigEntry @@ -8,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN from .coordinator import GoalZeroDataUpdateCoordinator @@ -17,6 +20,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" + + mac = entry.unique_id + + if TYPE_CHECKING: + assert mac is not None + + if (formatted_mac := format_mac(mac)) != mac: + # The DHCP discovery path did not format the MAC address + # so we need to update the config entry if it's different + hass.config_entries.async_update_entry(entry, unique_id=formatted_mac) + api = Yeti(entry.data[CONF_HOST], async_get_clientsession(hass)) try: await api.init_connect() diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 6d53628f21e27a..9464067d426273 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -22,7 +22,6 @@ BinarySensorEntityDescription( key="backlight", translation_key="backlight", - icon="mdi:clock-digital", ), BinarySensorEntityDescription( key="app_online", diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index 2d8c0c848c9797..2312b6bd1839f4 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -32,7 +32,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes """Handle dhcp discovery.""" self.ip_address = discovery_info.ip - await self.async_set_unique_id(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) self._abort_if_unique_id_configured(updates={CONF_HOST: self.ip_address}) self._async_abort_entries_match({CONF_HOST: self.ip_address}) diff --git a/homeassistant/components/goalzero/icons.json b/homeassistant/components/goalzero/icons.json new file mode 100644 index 00000000000000..0bb8b4162098b3 --- /dev/null +++ b/homeassistant/components/goalzero/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "binary_sensor": { + "backlight": { + "default": "mdi:clock-digital" + } + } + } +} diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index 0aebdb8c0734a1..f551e09fc6add0 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -35,7 +35,6 @@ class GoodweButtonEntityDescription( SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription( key="synchronize_clock", translation_key="synchronize_clock", - icon="mdi:clock-check-outline", entity_category=EntityCategory.CONFIG, action=lambda inv: inv.write_setting("time", datetime.now()), ) diff --git a/homeassistant/components/goodwe/icons.json b/homeassistant/components/goodwe/icons.json new file mode 100644 index 00000000000000..f5abd358baac0b --- /dev/null +++ b/homeassistant/components/goodwe/icons.json @@ -0,0 +1,22 @@ +{ + "entity": { + "button": { + "synchronize_clock": { + "default": "mdi:clock-check-outline" + } + }, + "number": { + "grid_export_limit": { + "default": "mdi:transmission-tower" + }, + "battery_discharge_depth": { + "default": "mdi:battery-arrow-down" + } + }, + "select": { + "operation_mode": { + "default": "mdi:solar-power" + } + } + } +} diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index d92f6ab8fd09ac..09e056da6078d1 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -51,7 +51,6 @@ def _get_setting_unit(inverter: Inverter, setting: str) -> str: GoodweNumberEntityDescription( key="grid_export_limit", translation_key="grid_export_limit", - icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, @@ -66,7 +65,6 @@ def _get_setting_unit(inverter: Inverter, setting: str) -> str: GoodweNumberEntityDescription( key="grid_export_limit", translation_key="grid_export_limit", - icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, native_unit_of_measurement=PERCENTAGE, native_step=1, diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index bc22376e4d904b..6d033eab242d61 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -31,7 +31,6 @@ OPERATION_MODE = SelectEntityDescription( key="operation_mode", - icon="mdi:solar-power", entity_category=EntityCategory.CONFIG, translation_key="operation_mode", ) diff --git a/homeassistant/components/google/icons.json b/homeassistant/components/google/icons.json new file mode 100644 index 00000000000000..6dbad61b43da04 --- /dev/null +++ b/homeassistant/components/google/icons.json @@ -0,0 +1,6 @@ +{ + "services": { + "add_event": "mdi:calendar-plus", + "create_event": "mdi:calendar-plus" + } +} diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d0705f9382a9e2..01c20595c557d3 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==6.1.1"] + "requirements": ["gcal-sync==6.0.3", "oauth2client==4.1.3", "ical==7.0.0"] } diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 94c97357b85461..139e3032f145f7 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -51,7 +51,9 @@ def __init__(self, project_id: str, google_config: GoogleConfig) -> None: async def async_press(self) -> None: """Press the button.""" assert self._context - agent_user_id = self._google_config.get_agent_user_id(self._context) + agent_user_id = self._google_config.get_agent_user_id_from_context( + self._context + ) result = await self._google_config.async_sync_entities(agent_user_id) if result != 200: raise HomeAssistantError( diff --git a/homeassistant/components/google_assistant/data_redaction.py b/homeassistant/components/google_assistant/data_redaction.py new file mode 100644 index 00000000000000..6a187113bb9213 --- /dev/null +++ b/homeassistant/components/google_assistant/data_redaction.py @@ -0,0 +1,78 @@ +"""Helpers to redact Google Assistant data when logging.""" +from __future__ import annotations + +from collections.abc import Callable +from functools import partial +from typing import Any + +from homeassistant.core import callback +from homeassistant.helpers.redact import REDACTED, async_redact_data, partial_redact + +GOOGLE_MSG_TO_REDACT: dict[str, Callable[[str], str]] = { + "agentUserId": partial_redact, + "uuid": partial_redact, + "webhookId": partial_redact, +} + +MDNS_TXT_TO_REDACT = [ + "location_name", + "uuid", + "external_url", + "internal_url", + "base_url", +] + + +def partial_redact_list_item(x: list[str], to_redact: list[str]) -> list[str]: + """Redact only specified string in a list of strings.""" + if not isinstance(x, list): + return x + result = [] + for itm in x: + if not isinstance(itm, str): + result.append(itm) + continue + for pattern in to_redact: + if itm.startswith(pattern): + result.append(f"{pattern}={REDACTED}") + break + else: + result.append(itm) + return result + + +def partial_redact_txt_list(x: list[str]) -> list[str]: + """Redact strings from home-assistant mDNS txt records.""" + return partial_redact_list_item(x, MDNS_TXT_TO_REDACT) + + +def partial_redact_txt_dict(x: dict[str, str]) -> dict[str, str]: + """Redact strings from home-assistant mDNS txt records.""" + if not isinstance(x, dict): + return x + result = {} + for k, v in x.items(): + result[k] = REDACTED if k in MDNS_TXT_TO_REDACT else v + return result + + +def partial_redact_string(x: str, to_redact: str) -> str: + """Redact only a specified string.""" + if x == to_redact: + return partial_redact(x) + return x + + +@callback +def async_redact_msg(msg: dict[str, Any], agent_user_id: str) -> dict[str, Any]: + """Mask sensitive data in message.""" + return async_redact_data( + msg, + GOOGLE_MSG_TO_REDACT + | { + "data": partial_redact_txt_list, + "id": partial(partial_redact_string, to_redact=agent_user_id), + "texts": partial_redact_txt_list, + "txt": partial_redact_txt_dict, + }, + ) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index f3d0d24f7c8cdb..28479fd1e97fdd 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from asyncio import gather -from collections.abc import Callable, Mapping +from collections.abc import Callable, Collection, Mapping from datetime import datetime, timedelta from functools import lru_cache from http import HTTPStatus @@ -15,7 +15,7 @@ from awesomeversion import AwesomeVersion from yarl import URL -from homeassistant.components import matter, webhook +from homeassistant.components import webhook from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, @@ -32,7 +32,7 @@ ) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import get_url -from homeassistant.helpers.storage import Store +from homeassistant.helpers.redact import partial_redact from homeassistant.util.dt import utcnow from . import trait @@ -45,9 +45,8 @@ ERR_FUNCTION_NOT_SUPPORTED, NOT_EXPOSE_LOCAL, SOURCE_LOCAL, - STORE_AGENT_USER_IDS, - STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) +from .data_redaction import async_redact_msg from .error import SmartHomeError SYNC_DELAY = 15 @@ -92,7 +91,6 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" - _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None def __init__(self, hass: HomeAssistant) -> None: @@ -103,12 +101,10 @@ def __init__(self, hass: HomeAssistant) -> None: self._local_last_active: datetime | None = None self._local_sdk_version_warn = False self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} + self._on_deinitialize: list[CALLBACK_TYPE] = [] async def async_initialize(self) -> None: """Perform async initialization of config.""" - self._store = GoogleConfigStore(self.hass) - await self._store.async_initialize() - if not self.enabled: return @@ -116,22 +112,29 @@ async def sync_google(_): """Sync entities to Google.""" await self.async_sync_entities_all() - start.async_at_start(self.hass, sync_google) + self._on_deinitialize.append(start.async_at_start(self.hass, sync_google)) + + @callback + def async_deinitialize(self) -> None: + """Remove listeners.""" + _LOGGER.debug("async_deinitialize") + while self._on_deinitialize: + self._on_deinitialize.pop()() @property + @abstractmethod def enabled(self): """Return if Google is enabled.""" - return False @property + @abstractmethod def entity_config(self): """Return entity config.""" - return {} @property + @abstractmethod def secure_devices_pin(self): """Return entity config.""" - return None @property def is_reporting_state(self): @@ -144,9 +147,9 @@ def is_local_sdk_active(self): return self._local_sdk_active @property + @abstractmethod def should_report_state(self): """Return if states should be proactively reported.""" - return False @property def is_local_connected(self) -> bool: @@ -157,48 +160,50 @@ def is_local_connected(self) -> bool: and self._local_last_active > utcnow() - timedelta(seconds=70) ) - def get_local_agent_user_id(self, webhook_id): - """Return the user ID to be used for actions received via the local SDK. + @abstractmethod + def get_local_user_id(self, webhook_id): + """Map webhook ID to a Home Assistant user ID. - Return None is no agent user id is found. - """ - found_agent_user_id = None - for agent_user_id, agent_user_data in self._store.agent_user_ids.items(): - if agent_user_data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] == webhook_id: - found_agent_user_id = agent_user_id - break + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. - return found_agent_user_id + Return None if no user id is found for the webhook_id. + """ + @abstractmethod def get_local_webhook_id(self, agent_user_id): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" - if data := self._store.agent_user_ids.get(agent_user_id): - return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] - return None @abstractmethod - def get_agent_user_id(self, context): + def get_agent_user_id_from_context(self, context): """Get agent user ID from context.""" + @abstractmethod + def get_agent_user_id_from_webhook(self, webhook_id): + """Map webhook ID to a Google agent user ID. + + Return None if no agent user id is found for the webhook_id. + """ + @abstractmethod def should_expose(self, state) -> bool: """Return if entity should be exposed.""" + @abstractmethod def should_2fa(self, state): """If an entity should have 2FA checked.""" - return True + @abstractmethod async def async_report_state( self, message: dict[str, Any], agent_user_id: str, event_id: str | None = None ) -> HTTPStatus | None: """Send a state report to Google.""" - raise NotImplementedError async def async_report_state_all(self, message): """Send a state report to Google for all previously synced users.""" jobs = [ self.async_report_state(message, agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ] await gather(*jobs) @@ -230,13 +235,13 @@ async def async_sync_entities(self, agent_user_id: str): async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return 204 res = await gather( *( self.async_sync_entities(agent_user_id) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=204) @@ -257,13 +262,13 @@ async def async_sync_notification_all( self, event_id: str, payload: dict[str, Any] ) -> HTTPStatus: """Sync notification to Google for all registered agents.""" - if not self._store.agent_user_ids: + if not self.async_get_agent_users(): return HTTPStatus.NO_CONTENT res = await gather( *( self.async_sync_notification(agent_user_id, event_id, payload) - for agent_user_id in self._store.agent_user_ids + for agent_user_id in self.async_get_agent_users() ) ) return max(res, default=HTTPStatus.NO_CONTENT) @@ -286,7 +291,7 @@ async def _schedule_callback(_now): @callback def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): self.async_schedule_google_sync(agent_user_id) async def _async_request_sync_devices(self, agent_user_id: str) -> int: @@ -296,13 +301,14 @@ async def _async_request_sync_devices(self, agent_user_id: str) -> int: """ raise NotImplementedError + @abstractmethod async def async_connect_agent_user(self, agent_user_id: str): """Add a synced and known agent_user_id. Called before sending a sync response to Google. """ - self._store.add_agent_user_id(agent_user_id) + @abstractmethod async def async_disconnect_agent_user(self, agent_user_id: str): """Turn off report state and disable further state reporting. @@ -311,7 +317,11 @@ async def async_disconnect_agent_user(self, agent_user_id: str): - When the cloud configuration is initialized - When sync entities fails with 404 """ - self._store.pop_agent_user_id(agent_user_id) + + @callback + @abstractmethod + def async_get_agent_users(self) -> Collection[str]: + """Return known agent users.""" @callback def async_enable_local_sdk(self) -> None: @@ -325,15 +335,15 @@ def async_enable_local_sdk(self) -> None: self._local_sdk_active = False return - for user_agent_id in self._store.agent_user_ids: + for user_agent_id in self.async_get_agent_users(): if (webhook_id := self.get_local_webhook_id(user_agent_id)) is None: setup_successful = False break _LOGGER.debug( "Register webhook handler %s for agent user id %s", - webhook_id, - user_agent_id, + partial_redact(webhook_id), + partial_redact(user_agent_id), ) try: webhook.async_register( @@ -348,8 +358,8 @@ def async_enable_local_sdk(self) -> None: except ValueError: _LOGGER.warning( "Webhook handler %s for agent user id %s is already defined!", - webhook_id, - user_agent_id, + partial_redact(webhook_id), + partial_redact(user_agent_id), ) setup_successful = False break @@ -370,12 +380,12 @@ def async_disable_local_sdk(self) -> None: if not self._local_sdk_active: return - for agent_user_id in self._store.agent_user_ids: + for agent_user_id in self.async_get_agent_users(): webhook_id = self.get_local_webhook_id(agent_user_id) _LOGGER.debug( "Unregister webhook handler %s for agent user id %s", - webhook_id, - agent_user_id, + partial_redact(webhook_id), + partial_redact(agent_user_id), ) webhook.async_unregister(self.hass, webhook_id) @@ -406,14 +416,17 @@ async def _handle_local_webhook(self, hass, webhook_id, request): payload = await request.json() if _LOGGER.isEnabledFor(logging.DEBUG): + msgid = "" + if isinstance(payload, dict): + msgid = payload.get("requestId") _LOGGER.debug( - "Received local message from %s (JS %s):\n%s\n", + "Received local message %s from %s (JS %s)", + msgid, request.remote, request.headers.get("HA-Cloud-Version", "unknown"), - pprint.pformat(payload), ) - if (agent_user_id := self.get_local_agent_user_id(webhook_id)) is None: + if (agent_user_id := self.get_agent_user_id_from_webhook(webhook_id)) is None: # No agent user linked to this webhook, means that the user has somehow unregistered # removing webhook and stopping processing of this request. _LOGGER.error( @@ -421,8 +434,8 @@ async def _handle_local_webhook(self, hass, webhook_id, request): "Cannot process request for webhook %s as no linked agent user is" " found:\n%s\n" ), - webhook_id, - pprint.pformat(payload), + partial_redact(webhook_id), + pprint.pformat(async_redact_msg(payload, agent_user_id)), ) webhook.async_unregister(self.hass, webhook_id) return None @@ -436,75 +449,20 @@ async def _handle_local_webhook(self, hass, webhook_id, request): self.hass, self, agent_user_id, + self.get_local_user_id(webhook_id), payload, SOURCE_LOCAL, ) if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Responding to local message:\n%s\n", pprint.pformat(result)) + if isinstance(payload, dict): + _LOGGER.debug("Responding to local message %s", msgid) + else: + _LOGGER.debug("Empty response to local message %s", msgid) return json_response(result) -class GoogleConfigStore: - """A configuration store for google assistant.""" - - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN - - def __init__(self, hass): - """Initialize a configuration store.""" - self._hass = hass - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) - self._data = None - - async def async_initialize(self): - """Finish initializing the ConfigStore.""" - should_save_data = False - if (data := await self._store.async_load()) is None: - # if the store is not found create an empty one - # Note that the first request is always a cloud request, - # and that will store the correct agent user id to be used for local requests - data = { - STORE_AGENT_USER_IDS: {}, - } - should_save_data = True - - for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): - if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: - data[STORE_AGENT_USER_IDS][agent_user_id] = { - **agent_user_data, - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - should_save_data = True - - if should_save_data: - await self._store.async_save(data) - - self._data = data - - @property - def agent_user_ids(self): - """Return a list of connected agent user_ids.""" - return self._data[STORE_AGENT_USER_IDS] - - @callback - def add_agent_user_id(self, agent_user_id): - """Add an agent user id to store.""" - if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS][agent_user_id] = { - STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), - } - self._store.async_delay_save(lambda: self._data, 1.0) - - @callback - def pop_agent_user_id(self, agent_user_id): - """Remove agent user id from store.""" - if agent_user_id in self._data[STORE_AGENT_USER_IDS]: - self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) - self._store.async_delay_save(lambda: self._data, 1.0) - - class RequestData: """Hold data associated with a particular request.""" @@ -697,16 +655,19 @@ def sync_serialize(self, agent_user_id, instance_uuid): return device # Add Matter info - if ( - "matter" in self.hass.config.components - and any(x for x in device_entry.identifiers if x[0] == "matter") - and ( - matter_info := matter.get_matter_device_info(self.hass, device_entry.id) - ) + if "matter" in self.hass.config.components and any( + x for x in device_entry.identifiers if x[0] == "matter" ): - device["matterUniqueId"] = matter_info["unique_id"] - device["matterOriginalVendorId"] = matter_info["vendor_id"] - device["matterOriginalProductId"] = matter_info["product_id"] + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.matter import get_matter_device_info + + # Import matter can block the event loop for multiple seconds + # so we import it here to avoid blocking the event loop during + # setup since google_assistant is imported from cloud. + if matter_info := get_matter_device_info(self.hass, device_entry.id): + device["matterUniqueId"] = matter_info["unique_id"] + device["matterOriginalVendorId"] = matter_info["vendor_id"] + device["matterOriginalProductId"] = matter_info["product_id"] # Add deviceInfo device_info = {} diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index c0e4f715c16946..0d75a1bede7912 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -1,7 +1,6 @@ """Support for Google Actions Smart Home Control.""" from __future__ import annotations -import asyncio from datetime import timedelta from http import HTTPStatus import logging @@ -12,14 +11,15 @@ from aiohttp.web import Request, Response import jwt +from homeassistant.components import webhook from homeassistant.components.http import HomeAssistantView from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES - -# Typing imports -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import dt as dt_util +from homeassistant.helpers.storage import STORAGE_DIR, Store +from homeassistant.util import dt as dt_util, json as json_util from .const import ( CONF_CLIENT_EMAIL, @@ -31,12 +31,15 @@ CONF_REPORT_STATE, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DOMAIN, GOOGLE_ASSISTANT_API_ENDPOINT, HOMEGRAPH_SCOPE, HOMEGRAPH_TOKEN_URL, REPORT_STATE_BASE_URL, REQUEST_SYNC_BASE_URL, SOURCE_CLOUD, + STORE_AGENT_USER_IDS, + STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) from .helpers import AbstractConfig from .smart_home import async_handle_message @@ -78,6 +81,8 @@ async def _get_homegraph_token( class GoogleConfig(AbstractConfig): """Config for manual setup of Google.""" + _store: GoogleConfigStore + def __init__(self, hass, config): """Initialize the config.""" super().__init__(hass) @@ -87,6 +92,10 @@ def __init__(self, hass, config): async def async_initialize(self): """Perform async initialization of config.""" + # We need to initialize the store before calling super + self._store = GoogleConfigStore(self.hass) + await self._store.async_initialize() + await super().async_initialize() self.async_enable_local_sdk() @@ -111,6 +120,45 @@ def should_report_state(self): """Return if states should be proactively reported.""" return self._config.get(CONF_REPORT_STATE) + def get_local_user_id(self, webhook_id): + """Map webhook ID to a Home Assistant user ID. + + Any action inititated by Google Assistant via the local SDK will be attributed + to the returned user ID. + + Return None if no user id is found for the webhook_id. + """ + # Note: The manually setup Google Assistant currently returns the Google agent + # user ID instead of a valid Home Assistant user ID + found_agent_user_id = None + for agent_user_id, agent_user_data in self._store.agent_user_ids.items(): + if agent_user_data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] == webhook_id: + found_agent_user_id = agent_user_id + break + + return found_agent_user_id + + def get_local_webhook_id(self, agent_user_id): + """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" + if data := self._store.agent_user_ids.get(agent_user_id): + return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] + return None + + def get_agent_user_id_from_context(self, context): + """Get agent user ID making request.""" + return context.user_id + + def get_agent_user_id_from_webhook(self, webhook_id): + """Map webhook ID to a Google agent user ID. + + Return None if no agent user id is found for the webhook_id. + """ + for agent_user_id, agent_user_data in self._store.agent_user_ids.items(): + if agent_user_data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] == webhook_id: + return agent_user_id + + return None + def should_expose(self, state) -> bool: """Return if entity should be exposed.""" expose_by_default = self._config.get(CONF_EXPOSE_BY_DEFAULT) @@ -150,10 +198,6 @@ def should_expose(self, state) -> bool: return is_default_exposed or explicit_expose - def get_agent_user_id(self, context): - """Get agent user ID making request.""" - return context.user_id - def should_2fa(self, state): """If an entity should have 2FA checked.""" return True @@ -167,6 +211,28 @@ async def _async_request_sync_devices(self, agent_user_id: str) -> HTTPStatus: _LOGGER.error("No configuration for request_sync available") return HTTPStatus.INTERNAL_SERVER_ERROR + async def async_connect_agent_user(self, agent_user_id: str): + """Add a synced and known agent_user_id. + + Called before sending a sync response to Google. + """ + self._store.add_agent_user_id(agent_user_id) + + async def async_disconnect_agent_user(self, agent_user_id: str): + """Turn off report state and disable further state reporting. + + Called when: + - The user disconnects their account from Google. + - When the cloud configuration is initialized + - When sync entities fails with 404 + """ + self._store.pop_agent_user_id(agent_user_id) + + @callback + def async_get_agent_users(self): + """Return known agent users.""" + return self._store.agent_user_ids + async def _async_update_token(self, force=False): if CONF_SERVICE_ACCOUNT not in self._config: _LOGGER.error("Trying to get homegraph api token without service account") @@ -216,7 +282,7 @@ async def _call(): except ClientResponseError as error: _LOGGER.error("Request for %s failed: %d", url, error.status) return error.status - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): _LOGGER.error("Could not contact %s", url) return HTTPStatus.INTERNAL_SERVER_ERROR @@ -234,6 +300,71 @@ async def async_report_state( return await self.async_call_homegraph_api(REPORT_STATE_BASE_URL, data) +class GoogleConfigStore: + """A configuration store for google assistant.""" + + _STORAGE_VERSION = 1 + _STORAGE_VERSION_MINOR = 2 + _STORAGE_KEY = DOMAIN + _data: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a configuration store.""" + self._hass = hass + self._store: Store[dict[str, Any]] = Store( + hass, + self._STORAGE_VERSION, + self._STORAGE_KEY, + minor_version=self._STORAGE_VERSION_MINOR, + ) + + async def async_initialize(self) -> None: + """Finish initializing the ConfigStore.""" + should_save_data = False + if (data := await self._store.async_load()) is None: + # if the store is not found create an empty one + # Note that the first request is always a cloud request, + # and that will store the correct agent user id to be used for local requests + data = { + STORE_AGENT_USER_IDS: {}, + } + should_save_data = True + + for agent_user_id, agent_user_data in data[STORE_AGENT_USER_IDS].items(): + if STORE_GOOGLE_LOCAL_WEBHOOK_ID not in agent_user_data: + data[STORE_AGENT_USER_IDS][agent_user_id] = { + **agent_user_data, + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + should_save_data = True + + if should_save_data: + await self._store.async_save(data) + + self._data = data + + @property + def agent_user_ids(self) -> dict[str, Any]: + """Return a list of connected agent user_ids.""" + return self._data[STORE_AGENT_USER_IDS] + + @callback + def add_agent_user_id(self, agent_user_id: str) -> None: + """Add an agent user id to store.""" + if agent_user_id not in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS][agent_user_id] = { + STORE_GOOGLE_LOCAL_WEBHOOK_ID: webhook.async_generate_id(), + } + self._store.async_delay_save(lambda: self._data, 1.0) + + @callback + def pop_agent_user_id(self, agent_user_id: str) -> None: + """Remove agent user id from store.""" + if agent_user_id in self._data[STORE_AGENT_USER_IDS]: + self._data[STORE_AGENT_USER_IDS].pop(agent_user_id, None) + self._store.async_delay_save(lambda: self._data, 1.0) + + class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" @@ -252,7 +383,31 @@ async def post(self, request: Request) -> Response: request.app["hass"], self.config, request["hass_user"].id, + request["hass_user"].id, message, SOURCE_CLOUD, ) return self.json(result) + + +async def async_get_users(hass: HomeAssistant) -> list[str]: + """Return stored users. + + This is called by the cloud integration to import from the previously shared store. + """ + # pylint: disable-next=protected-access + path = hass.config.path(STORAGE_DIR, GoogleConfigStore._STORAGE_KEY) + try: + store_data = await hass.async_add_executor_job(json_util.load_json, path) + except HomeAssistantError: + return [] + + if ( + not isinstance(store_data, dict) + or not (data := store_data.get("data")) + or not isinstance(data, dict) + or not (agent_user_ids := data.get("agent_user_ids")) + or not isinstance(agent_user_ids, dict) + ): + return [] + return list(agent_user_ids) diff --git a/homeassistant/components/google_assistant/icons.json b/homeassistant/components/google_assistant/icons.json new file mode 100644 index 00000000000000..3bcab03d2c240b --- /dev/null +++ b/homeassistant/components/google_assistant/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "request_sync": "mdi:sync" + } +} diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index b8c57812540cfe..8172d0ca92d0e3 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Coroutine from itertools import product import logging +import pprint from typing import Any from homeassistant.const import ATTR_ENTITY_ID, __version__ @@ -18,6 +19,7 @@ EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED, ) +from .data_redaction import async_redact_msg from .error import SmartHomeError from .helpers import GoogleEntity, RequestData, async_get_entities @@ -33,16 +35,36 @@ _LOGGER = logging.getLogger(__name__) -async def async_handle_message(hass, config, user_id, message, source): +async def async_handle_message( + hass, config, agent_user_id, local_user_id, message, source +): """Handle incoming API messages.""" + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Processing message:\n%s", + pprint.pformat(async_redact_msg(message, agent_user_id)), + ) + data = RequestData( - config, user_id, source, message["requestId"], message.get("devices") + config, local_user_id, source, message["requestId"], message.get("devices") ) response = await _process(hass, data, message) + if _LOGGER.isEnabledFor(logging.DEBUG): + if response: + _LOGGER.debug( + "Response:\n%s", + pprint.pformat(async_redact_msg(response["payload"], agent_user_id)), + ) + else: + _LOGGER.debug("Empty response") if response and "errorCode" in response["payload"]: - _LOGGER.error("Error handling message %s: %s", message, response["payload"]) + _LOGGER.error( + "Error handling message\n:%s\nResponse:\n%s", + pprint.pformat(async_redact_msg(message, agent_user_id)), + pprint.pformat(async_redact_msg(response["payload"], agent_user_id)), + ) return response @@ -112,14 +134,12 @@ async def async_devices_sync( context=data.context, ) - agent_user_id = data.config.get_agent_user_id(data.context) + agent_user_id = data.config.get_agent_user_id_from_context(data.context) await data.config.async_connect_agent_user(agent_user_id) devices = await async_devices_sync_response(hass, data.config, agent_user_id) response = create_sync_response(agent_user_id, devices) - _LOGGER.debug("Syncing entities response: %s", response) - return response @@ -246,7 +266,7 @@ async def handle_devices_execute( for entity_id, result in zip(executions, execute_results): if result is not None: results[entity_id] = result - except asyncio.TimeoutError: + except TimeoutError: pass final_results = list(results.values()) @@ -290,7 +310,7 @@ async def async_devices_identify( """ return { "device": { - "id": data.config.get_agent_user_id(data.context), + "id": data.config.get_agent_user_id_from_context(data.context), "isLocalOnly": True, "isProxy": True, "deviceInfo": { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index bb03e796d9142c..169fa30386df5f 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1942,9 +1942,7 @@ def query_attributes(self): elif self.state.domain == media_player.DOMAIN: if media_player.ATTR_SOUND_MODE_LIST in attrs: mode_settings["sound mode"] = attrs.get(media_player.ATTR_SOUND_MODE) - elif self.state.domain == input_select.DOMAIN: - mode_settings["option"] = self.state.state - elif self.state.domain == select.DOMAIN: + elif self.state.domain in (input_select.DOMAIN, select.DOMAIN): mode_settings["option"] = self.state.state elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: diff --git a/homeassistant/components/google_assistant_sdk/icons.json b/homeassistant/components/google_assistant_sdk/icons.json new file mode 100644 index 00000000000000..bf1420b2e3febf --- /dev/null +++ b/homeassistant/components/google_assistant_sdk/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "send_text_command": "mdi:comment-text-outline" + } +} diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 720c7d9aa2bda4..8f30448ad61f8f 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -292,7 +292,7 @@ async def async_get_tts_audio(self, message, language, options): ) return _encoding, response.audio_content - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex) except Exception as ex: # pylint: disable=broad-except _LOGGER.exception("Error occurred during Google Cloud TTS call: %s", ex) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index 52dcdb61e8fa55..1d420cb1497e41 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -80,7 +80,7 @@ async def _update_google_domains(hass, session, domain, user, password, timeout) except aiohttp.ClientError: _LOGGER.warning("Can't connect to Google Domains API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from Google Domains API for domain: %s", domain) return False diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a522eeab5cd073..73450e9f5b97a6 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -63,7 +63,9 @@ async def generate_content(call: ServiceCall) -> ServiceResponse: for image_filename in image_filenames: if not hass.config.is_allowed_path(image_filename): raise HomeAssistantError( - f"Cannot read `{image_filename}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + f"Cannot read `{image_filename}`, no access to path; " + "`allowlist_external_dirs` may need to be adjusted in " + "`configuration.yaml`" ) if not Path(image_filename).exists(): raise HomeAssistantError(f"`{image_filename}` does not exist") diff --git a/homeassistant/components/google_generative_ai_conversation/icons.json b/homeassistant/components/google_generative_ai_conversation/icons.json new file mode 100644 index 00000000000000..6544532783a053 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "generate_content": "mdi:receipt-text" + } +} diff --git a/homeassistant/components/google_mail/icons.json b/homeassistant/components/google_mail/icons.json new file mode 100644 index 00000000000000..599ccffe3c71c7 --- /dev/null +++ b/homeassistant/components/google_mail/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "set_vacation": "mdi:beach" + } +} diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py index dc1ee33c16ed8d..78b0e3c9a91a4b 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -22,7 +22,6 @@ SENSOR_TYPE = SensorEntityDescription( key="vacation_end_date", translation_key="vacation_end_date", - icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, ) diff --git a/homeassistant/components/google_sheets/icons.json b/homeassistant/components/google_sheets/icons.json new file mode 100644 index 00000000000000..c8010a690bec04 --- /dev/null +++ b/homeassistant/components/google_sheets/icons.json @@ -0,0 +1,5 @@ +{ + "services": { + "append_sheet": "mdi:google-spreadsheet" + } +} diff --git a/homeassistant/components/govee_light_local/__init__.py b/homeassistant/components/govee_light_local/__init__.py index ab20f4cefcd05e..2d4594755c475a 100644 --- a/homeassistant/components/govee_light_local/__init__.py +++ b/homeassistant/components/govee_light_local/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with asyncio.timeout(delay=5): while not coordinator.devices: await asyncio.sleep(delay=1) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/govee_light_local/config_flow.py b/homeassistant/components/govee_light_local/config_flow.py index 8ab14966828c3a..8058668f0ca3b7 100644 --- a/homeassistant/components/govee_light_local/config_flow.py +++ b/homeassistant/components/govee_light_local/config_flow.py @@ -44,7 +44,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: async with asyncio.timeout(delay=5): while not controller.devices: await asyncio.sleep(delay=1) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.debug("No devices found") devices_count = len(controller.devices) diff --git a/homeassistant/components/gpsd/icons.json b/homeassistant/components/gpsd/icons.json new file mode 100644 index 00000000000000..b29640e0001c88 --- /dev/null +++ b/homeassistant/components/gpsd/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "sensor": { + "mode": { + "default": "mdi:crosshairs", + "state": { + "2d_fix": "mdi:crosshairs-gps", + "3d_fix": "mdi:crosshairs-gps" + } + } + } + } +} diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 932db0815985ed..135d9c6c28fbf4 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -1,6 +1,8 @@ -"""Support for GPSD.""" +"""Sensor platform for GPSD integration.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging from typing import Any @@ -15,6 +17,7 @@ PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -24,6 +27,7 @@ CONF_HOST, CONF_NAME, CONF_PORT, + EntityCategory, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv @@ -43,6 +47,28 @@ DEFAULT_NAME = "GPS" +_MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} + + +@dataclass(frozen=True, kw_only=True) +class GpsdSensorDescription(SensorEntityDescription): + """Class describing GPSD sensor entities.""" + + value_fn: Callable[[AGPS3mechanism], str | None] + + +SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = ( + GpsdSensorDescription( + key="mode", + translation_key="mode", + name=None, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=list(_MODE_VALUES.values()), + value_fn=lambda agps_thread: _MODE_VALUES.get(agps_thread.data_stream.mode), + ), +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -64,7 +90,9 @@ async def async_setup_entry( config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], config_entry.entry_id, + description, ) + for description in SENSOR_TYPES ] ) @@ -101,23 +129,23 @@ class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" _attr_has_entity_name = True - _attr_name = None - _attr_translation_key = "mode" - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = ["2d_fix", "3d_fix"] + + entity_description: GpsdSensorDescription def __init__( self, host: str, port: int, unique_id: str, + description: GpsdSensorDescription, ) -> None: """Initialize the GPSD sensor.""" + self.entity_description = description self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, entry_type=DeviceEntryType.SERVICE, ) - self._attr_unique_id = f"{unique_id}-mode" + self._attr_unique_id = f"{unique_id}-{self.entity_description.key}" self.agps_thread = AGPS3mechanism() self.agps_thread.stream_data(host=host, port=port) @@ -126,11 +154,7 @@ def __init__( @property def native_value(self) -> str | None: """Return the state of GPSD.""" - if self.agps_thread.data_stream.mode == 3: - return "3d_fix" - if self.agps_thread.data_stream.mode == 2: - return "2d_fix" - return None + return self.entity_description.value_fn(self.agps_thread) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -144,12 +168,3 @@ def extra_state_attributes(self) -> dict[str, Any]: ATTR_CLIMB: self.agps_thread.data_stream.climb, ATTR_MODE: self.agps_thread.data_stream.mode, } - - @property - def icon(self) -> str: - """Return the icon of the sensor.""" - mode = self.agps_thread.data_stream.mode - - if isinstance(mode, int) and mode >= 2: - return "mdi:crosshairs-gps" - return "mdi:crosshairs" diff --git a/homeassistant/components/gree/icons.json b/homeassistant/components/gree/icons.json new file mode 100644 index 00000000000000..ac8e45ebf893eb --- /dev/null +++ b/homeassistant/components/gree/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "light": { + "default": "mdi:lightbulb" + }, + "health_mode": { + "default": "mdi:pine-tree" + } + } + } +} diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 07e88223306073..e18cf28e17414f 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -56,7 +56,6 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( - icon="mdi:lightbulb", key="Panel Light", translation_key="light", get_value_fn=lambda d: d.light, @@ -81,7 +80,6 @@ def _set_anion(device: Device, value: bool) -> None: set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( - icon="mdi:pine-tree", key="Health mode", translation_key="health_mode", get_value_fn=lambda d: d.anion, diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 894a20629eea7a..9ee81191bf8e27 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -383,7 +383,8 @@ async def groups_service_handler(service: ServiceCall) -> None: return True -async def _process_group_platform( +@callback +def _process_group_platform( hass: HomeAssistant, domain: str, platform: GroupProtocol ) -> None: """Process a group platform.""" diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 5a11349189138b..c8689cdaa1c612 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -30,6 +30,7 @@ ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -162,6 +163,9 @@ def __init__( if mode: self.mode = all + self._attr_color_mode = ColorMode.UNKNOWN + self._attr_supported_color_modes = {ColorMode.ONOFF} + async def async_turn_on(self, **kwargs: Any) -> None: """Forward the turn_on command to all lights in the light group.""" data = { @@ -261,26 +265,36 @@ def async_update_group_state(self) -> None: effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] - self._attr_color_mode = None - all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) - if all_color_modes: - # Report the most common color mode, select brightness and onoff last - color_mode_count = Counter(itertools.chain(all_color_modes)) - if ColorMode.ONOFF in color_mode_count: - color_mode_count[ColorMode.ONOFF] = -1 - if ColorMode.BRIGHTNESS in color_mode_count: - color_mode_count[ColorMode.BRIGHTNESS] = 0 - self._attr_color_mode = color_mode_count.most_common(1)[0][0] - - self._attr_supported_color_modes = None + supported_color_modes = {ColorMode.ONOFF} all_supported_color_modes = list( find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) ) if all_supported_color_modes: # Merge all color modes. - self._attr_supported_color_modes = cast( - set[str], set().union(*all_supported_color_modes) + supported_color_modes = filter_supported_color_modes( + cast(set[ColorMode], set().union(*all_supported_color_modes)) ) + self._attr_supported_color_modes = supported_color_modes + + self._attr_color_mode = ColorMode.UNKNOWN + all_color_modes = list(find_state_attributes(on_states, ATTR_COLOR_MODE)) + if all_color_modes: + # Report the most common color mode, select brightness and onoff last + color_mode_count = Counter(itertools.chain(all_color_modes)) + if ColorMode.ONOFF in color_mode_count: + if ColorMode.ONOFF in supported_color_modes: + color_mode_count[ColorMode.ONOFF] = -1 + else: + color_mode_count.pop(ColorMode.ONOFF) + if ColorMode.BRIGHTNESS in color_mode_count: + if ColorMode.BRIGHTNESS in supported_color_modes: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + else: + color_mode_count.pop(ColorMode.BRIGHTNESS) + if color_mode_count: + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + else: + self._attr_color_mode = next(iter(supported_color_modes)) self._attr_supported_features = LightEntityFeature(0) for support in find_state_attributes(states, ATTR_SUPPORTED_FEATURES): diff --git a/homeassistant/components/guardian/icons.json b/homeassistant/components/guardian/icons.json new file mode 100644 index 00000000000000..4740366e993000 --- /dev/null +++ b/homeassistant/components/guardian/icons.json @@ -0,0 +1,25 @@ +{ + "entity": { + "sensor": { + "uptime": { + "default": "mdi:timer" + }, + "travel_count": { + "default": "mdi:counter" + } + }, + "switch": { + "onboard_access_point": { + "default": "mdi:wifi" + }, + "valve_controller": { + "default": "mdi:water" + } + } + }, + "services": { + "pair_sensor": "mdi:link-variant", + "unpair_sensor": "mdi:link-variant-remove", + "upgrade_firmware": "mdi:update" + } +} diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 64c70b07b83549..1941dc54248819 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -119,7 +119,6 @@ class ValveControllerSensorDescription( ValveControllerSensorDescription( key=SENSOR_KIND_UPTIME, translation_key="uptime", - icon="mdi:timer", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MINUTES, api_category=API_SYSTEM_DIAGNOSTICS, @@ -128,7 +127,6 @@ class ValveControllerSensorDescription( ValveControllerSensorDescription( key=SENSOR_KIND_TRAVEL_COUNT, translation_key="travel_count", - icon="mdi:counter", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="revolutions", api_category=API_VALVE_STATUS, diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 7db0fde89055fe..ebe8e5549ceeb0 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -80,7 +80,6 @@ def is_open(data: dict[str, Any]) -> bool: ValveControllerSwitchDescription( key=SWITCH_KIND_ONBOARD_AP, translation_key="onboard_access_point", - icon="mdi:wifi", entity_category=EntityCategory.CONFIG, extra_state_attributes_fn=lambda data: { ATTR_CONNECTED_CLIENTS: data.get("ap_clients"), @@ -94,7 +93,6 @@ def is_open(data: dict[str, Any]) -> bool: ValveControllerSwitchDescription( key=SWITCH_KIND_VALVE, translation_key="valve_controller", - icon="mdi:water", api_category=API_VALVE_STATUS, extra_state_attributes_fn=lambda data: { ATTR_AVG_CURRENT: data["average_current"], diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index ffa5732255105f..a5e91dce8130bc 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -48,9 +48,10 @@ def async_finish_entity_domain_replacements( try: [registry_entry] = [ registry_entry - for registry_entry in ent_reg.entities.values() - if registry_entry.config_entry_id == entry.entry_id - and registry_entry.domain == strategy.old_domain + for registry_entry in er.async_entries_for_config_entry( + ent_reg, entry.entry_id + ) + if registry_entry.domain == strategy.old_domain and registry_entry.unique_id == strategy.old_unique_id ] except ValueError: diff --git a/homeassistant/components/hardkernel/__init__.py b/homeassistant/components/hardkernel/__init__.py index c81ad7860be79f..41e65ff8b5ea68 100644 --- a/homeassistant/components/hardkernel/__init__.py +++ b/homeassistant/components/hardkernel/__init__.py @@ -1,7 +1,7 @@ """The Hardkernel integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Hardkernel config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py index 3d4a87b04074fe..c94de0db68d987 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -12,6 +12,7 @@ "odroid-c2": "Hardkernel ODROID-C2", "odroid-c4": "Hardkernel ODROID-C4", "odroid-m1": "Hardkernel ODROID-M1", + "odroid-m1s": "Hardkernel ODROID-M1S", "odroid-n2": "Home Assistant Blue / Hardkernel ODROID-N2/N2+", "odroid-xu4": "Hardkernel ODROID-XU4", } diff --git a/homeassistant/components/hardkernel/manifest.json b/homeassistant/components/hardkernel/manifest.json index 1b29f0b0b227d9..2a528a5173ea50 100644 --- a/homeassistant/components/hardkernel/manifest.json +++ b/homeassistant/components/hardkernel/manifest.json @@ -1,9 +1,10 @@ { "domain": "hardkernel", "name": "Hardkernel", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio"], + "dependencies": ["hardware"], "documentation": "https://www.home-assistant.io/integrations/hardkernel", "integration_type": "hardware" } diff --git a/homeassistant/components/hardware/hardware.py b/homeassistant/components/hardware/hardware.py index cc904fbf131923..d44a232c232a69 100644 --- a/homeassistant/components/hardware/hardware.py +++ b/homeassistant/components/hardware/hardware.py @@ -1,7 +1,7 @@ """The Hardware integration.""" from __future__ import annotations -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -18,7 +18,8 @@ async def async_process_hardware_platforms(hass: HomeAssistant) -> None: await async_process_integration_platforms(hass, DOMAIN, _register_hardware_platform) -async def _register_hardware_platform( +@callback +def _register_hardware_platform( hass: HomeAssistant, integration_domain: str, platform: HardwareProtocol ) -> None: """Register a hardware platform.""" diff --git a/homeassistant/components/hardware/manifest.json b/homeassistant/components/hardware/manifest.json index f2772e609db4ff..8056a4cca4fffc 100644 --- a/homeassistant/components/hardware/manifest.json +++ b/homeassistant/components/hardware/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@home-assistant/core"], "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/hardware", + "import_executor": true, "integration_type": "system", "quality_scale": "internal", "requirements": ["psutil-home-assistant==0.0.1"] diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py index 918c96c5643292..d4e4f2fed5c999 100644 --- a/homeassistant/components/hardware/websocket_api.py +++ b/homeassistant/components/hardware/websocket_api.py @@ -66,13 +66,13 @@ async def ws_info( connection.send_result(msg["id"], {"hardware": hardware_info}) +@callback @websocket_api.websocket_command( { vol.Required("type"): "hardware/subscribe_system_status", } ) -@websocket_api.async_response -async def ws_subscribe_system_status( +def ws_subscribe_system_status( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Subscribe to system status updates.""" diff --git a/homeassistant/components/harmony/data.py b/homeassistant/components/harmony/data.py index 44c0fde19c194e..f7eb96d6a8fd40 100644 --- a/homeassistant/components/harmony/data.py +++ b/homeassistant/components/harmony/data.py @@ -1,7 +1,6 @@ """Harmony data object which contains the Harmony Client.""" from __future__ import annotations -import asyncio from collections.abc import Iterable import logging @@ -121,7 +120,7 @@ async def connect(self) -> None: connected = False try: connected = await self._client.connect() - except (asyncio.TimeoutError, aioexc.TimeOut) as err: + except (TimeoutError, aioexc.TimeOut) as err: await self._client.close() raise ConfigEntryNotReady( f"{self._name}: Connection timed-out to {self._address}:8088" diff --git a/homeassistant/components/harmony/icons.json b/homeassistant/components/harmony/icons.json new file mode 100644 index 00000000000000..f96fd985323ded --- /dev/null +++ b/homeassistant/components/harmony/icons.json @@ -0,0 +1,16 @@ +{ + "entity": { + "select": { + "activities": { + "default": "mdi:remote-tv", + "state": { + "power_off": "mdi:remote-tv-off" + } + } + } + }, + "services": { + "sync": "mdi:sync", + "change_channel": "mdi:remote-tv" + } +} diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py index e98a15c788fd0a..f08030c0152eb6 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -43,13 +43,6 @@ def __init__(self, name: str, data: HarmonyData) -> None: self._attr_device_info = self._data.device_info(DOMAIN) self._attr_name = name - @property - def icon(self) -> str: - """Return a representative icon.""" - if not self.available or self.current_option == TRANSLATABLE_POWER_OFF: - return "mdi:remote-tv-off" - return "mdi:remote-tv" - @property def options(self) -> list[str]: """Return a set of selectable options.""" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 1472843e14d3fb..e367a935ace042 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -42,6 +42,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.loader import bind_hass +from homeassistant.util.async_ import create_eager_task from homeassistant.util.dt import now from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401 @@ -264,6 +265,7 @@ class APIEndpointSettings(NamedTuple): "odroid-c2": "hardkernel", "odroid-c4": "hardkernel", "odroid-m1": "hardkernel", + "odroid-m1s": "hardkernel", "odroid-n2": "hardkernel", "odroid-xu4": "hardkernel", "rpi2": "raspberry_pi", @@ -503,7 +505,7 @@ async def push_config(_: Event | None) -> None: hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config) - await push_config(None) + push_config_task = hass.async_create_task(push_config(None), eager_start=True) async def async_service_handler(service: ServiceCall) -> None: """Handle service calls for Hass.io.""" @@ -546,12 +548,12 @@ async def update_info_data(_: datetime | None = None) -> None: hass.data[DATA_SUPERVISOR_INFO], hass.data[DATA_OS_INFO], ) = await asyncio.gather( - hassio.get_info(), - hassio.get_host_info(), - hassio.get_store(), - hassio.get_core_info(), - hassio.get_supervisor_info(), - hassio.get_os_info(), + create_eager_task(hassio.get_info()), + create_eager_task(hassio.get_host_info()), + create_eager_task(hassio.get_store()), + create_eager_task(hassio.get_core_info()), + create_eager_task(hassio.get_supervisor_info()), + create_eager_task(hassio.get_os_info()), ) except HassioAPIError as err: @@ -565,6 +567,7 @@ async def update_info_data(_: datetime | None = None) -> None: # Fetch data await update_info_data() + await push_config_task async def _async_stop(hass: HomeAssistant, restart: bool) -> None: """Stop or restart home assistant.""" @@ -590,8 +593,9 @@ async def _async_stop(hass: HomeAssistant, restart: bool) -> None: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(_: datetime | None = None) -> None: - """Set up hardaware integration for the detected board type.""" + @callback + def _async_setup_hardware_integration(_: datetime | None = None) -> None: + """Set up hardware integration for the detected board type.""" if (os_info := get_os_info(hass)) is None: # os info not yet fetched from supervisor, retry later async_call_later( @@ -614,10 +618,11 @@ async def _async_setup_hardware_integration(_: datetime | None = None) -> None: _async_setup_hardware_integration, cancel_on_shutdown=True ) - await _async_setup_hardware_integration() + _async_setup_hardware_integration() hass.async_create_task( - hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) + hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}), + eager_start=True, ) # Start listening for problems with supervisor and making issues diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 8ebf4bf5ccab50..db53b2f90fcf1c 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -1,5 +1,4 @@ """Implement the Ingress Panel feature for Hass.io Add-ons.""" -import asyncio from http import HTTPStatus import logging from typing import Any @@ -27,18 +26,13 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None: return # Register available panels - jobs: list[asyncio.Task[None]] = [] for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue - jobs.append( - asyncio.create_task( - _register_panel(hass, addon, data), name=f"register panel {addon}" - ) - ) - - if jobs: - await asyncio.wait(jobs) + # _register_panel never suspends and is only + # a coroutine because it would be a breaking change + # to make it a normal function + await _register_panel(hass, addon, data) class HassIOAddonPanel(HomeAssistantView): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 8d78c878cfa7da..9b8e6367647f22 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -2,11 +2,11 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine from http import HTTPStatus import logging import os -from typing import Any +from typing import Any, ParamSpec import aiohttp from yarl import URL @@ -23,6 +23,8 @@ from .const import ATTR_DISCOVERY, DOMAIN, X_HASS_SOURCE +_P = ParamSpec("_P") + _LOGGER = logging.getLogger(__name__) @@ -30,10 +32,12 @@ class HassioAPIError(RuntimeError): """Return if a API trow a error.""" -def _api_bool(funct): +def _api_bool( + funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], +) -> Callable[_P, Coroutine[Any, Any, bool]]: """Return a boolean.""" - async def _wrapper(*argv, **kwargs): + async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> bool: """Wrap function.""" try: data = await funct(*argv, **kwargs) @@ -44,10 +48,12 @@ async def _wrapper(*argv, **kwargs): return _wrapper -def api_data(funct): +def api_data( + funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]], +) -> Callable[_P, Coroutine[Any, Any, Any]]: """Return data of an api.""" - async def _wrapper(*argv, **kwargs): + async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> Any: """Wrap function.""" data = await funct(*argv, **kwargs) if data["result"] == "ok": @@ -80,7 +86,7 @@ async def async_get_addon_store_info(hass: HomeAssistant, slug: str) -> dict: @bind_hass -async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> dict: +async def async_update_diagnostics(hass: HomeAssistant, diagnostics: bool) -> bool: """Update Supervisor diagnostics toggle. The caller of the function should handle HassioAPIError. @@ -255,7 +261,7 @@ async def async_update_core( @bind_hass @_api_bool -async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool: +async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: """Apply a suggestion from supervisor's resolution center. The caller of the function should handle HassioAPIError. @@ -583,7 +589,7 @@ async def send_command( timeout=aiohttp.ClientTimeout(total=timeout), ) - if request.status not in (HTTPStatus.OK, HTTPStatus.BAD_REQUEST): + if request.status != HTTPStatus.OK: _LOGGER.error("%s return code %d", command, request.status) raise HassioAPIError() @@ -592,7 +598,7 @@ async def send_command( return await request.json(encoding="utf-8") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout on %s request", command) except aiohttp.ClientError as err: diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 9d72d5842fdc70..d86f1b7dc5c6fb 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -1,7 +1,6 @@ """HTTP Support for Hass.io.""" from __future__ import annotations -import asyncio from http import HTTPStatus import logging import os @@ -193,7 +192,7 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Client timeout error on API request %s", path) raise HTTPBadGateway() @@ -225,4 +224,10 @@ def should_compress(content_type: str) -> bool: """Return if we should compress a response.""" if content_type.startswith("image/"): return "svg" in content_type + if content_type.startswith("application/"): + return ( + "json" in content_type + or "xml" in content_type + or "javascript" in content_type + ) return not content_type.startswith(("video/", "audio/", "font/")) diff --git a/homeassistant/components/hassio/icons.json b/homeassistant/components/hassio/icons.json new file mode 100644 index 00000000000000..c55820b58f2aab --- /dev/null +++ b/homeassistant/components/hassio/icons.json @@ -0,0 +1,25 @@ +{ + "entity": { + "sensor": { + "cpu_percent": { + "default": "mdi:cpu-64-bit" + }, + "memory_percent": { + "default": "mdi:memory" + } + } + }, + "services": { + "addon_start": "mdi:play", + "addon_restart": "mdi:restart", + "addon_stdin": "mdi:console", + "addon_stop": "mdi:stop", + "addon_update": "mdi:update", + "host_reboot": "mdi:restart", + "host_shutdown": "mdi:power", + "backup_full": "mdi:content-save", + "backup_partial": "mdi:content-save", + "restore_full": "mdi:backup-restore", + "restore_partial": "mdi:backup-restore" + } +} diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 4f3933d0f5c56a..f9ff1dd777079d 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import UNDEFINED +from homeassistant.util.async_ import create_eager_task from .const import X_HASS_SOURCE, X_INGRESS_PATH from .http import should_compress @@ -143,8 +144,8 @@ async def _handle_websocket( # Proxy requests await asyncio.wait( [ - asyncio.create_task(_websocket_forward(ws_server, ws_client)), - asyncio.create_task(_websocket_forward(ws_client, ws_server)), + create_eager_task(_websocket_forward(ws_server, ws_client)), + create_eager_task(_websocket_forward(ws_client, ws_server)), ], return_when=asyncio.FIRST_COMPLETED, ) @@ -288,13 +289,13 @@ async def _websocket_forward( """Handle websocket message directly.""" try: async for msg in ws_from: - if msg.type == aiohttp.WSMsgType.TEXT: + if msg.type is aiohttp.WSMsgType.TEXT: await ws_to.send_str(msg.data) - elif msg.type == aiohttp.WSMsgType.BINARY: + elif msg.type is aiohttp.WSMsgType.BINARY: await ws_to.send_bytes(msg.data) - elif msg.type == aiohttp.WSMsgType.PING: + elif msg.type is aiohttp.WSMsgType.PING: await ws_to.ping() - elif msg.type == aiohttp.WSMsgType.PONG: + elif msg.type is aiohttp.WSMsgType.PONG: await ws_to.pong() elif ws_to.closed: await ws_to.close(code=ws_to.close_code, message=msg.extra) # type: ignore[arg-type] diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index b49433961e354d..0214f28011d372 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -50,7 +50,6 @@ entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, translation_key="cpu_percent", - icon="mdi:cpu-64-bit", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -58,7 +57,6 @@ entity_registry_enabled_default=False, key=ATTR_MEMORY_PERCENT, translation_key="memory_percent", - icon="mdi:memory", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index ae04aa0fff58b9..cf59f8de7f74be 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -61,10 +61,10 @@ def async_load_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_subscribe) +@callback @websocket_api.require_admin @websocket_api.websocket_command({vol.Required(WS_TYPE): WS_TYPE_SUBSCRIBE}) -@websocket_api.async_response -async def websocket_subscribe( +def websocket_subscribe( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Subscribe to supervisor events.""" @@ -80,14 +80,14 @@ def forward_messages(data: dict[str, str]) -> None: connection.send_message(websocket_api.result_message(msg[WS_ID])) +@callback @websocket_api.websocket_command( { vol.Required(WS_TYPE): WS_TYPE_EVENT, vol.Required(ATTR_DATA): SCHEMA_WEBSOCKET_EVENT, } ) -@websocket_api.async_response -async def websocket_supervisor_event( +def websocket_supervisor_event( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Publish events from the Supervisor.""" diff --git a/homeassistant/components/havana_shade/__init__.py b/homeassistant/components/havana_shade/__init__.py new file mode 100644 index 00000000000000..3eb027f87a651e --- /dev/null +++ b/homeassistant/components/havana_shade/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Havana Shade.""" diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json new file mode 100644 index 00000000000000..69c434c8287b93 --- /dev/null +++ b/homeassistant/components/heos/icons.json @@ -0,0 +1,6 @@ +{ + "services": { + "sign_in": "mdi:login", + "sign_out": "mdi:logout" + } +} diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 25422004797c23..f903a9904a91ef 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -179,8 +179,8 @@ def _generate_stream_message( """Generate a history stream message response.""" return { "states": states, - "start_time": dt_util.utc_to_timestamp(start_day), - "end_time": dt_util.utc_to_timestamp(end_day), + "start_time": start_day.timestamp(), + "end_time": end_day.timestamp(), } diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index 0b10130a88f84a..46cc37751a02ba 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -20,7 +20,6 @@ from . import HiveEntity from .const import DOMAIN -ICON = "mdi:security" PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) HIVETOHA = { @@ -46,7 +45,6 @@ async def async_setup_entry( class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): """Representation of a Hive alarm.""" - _attr_icon = ICON _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_NIGHT | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/hive/icons.json b/homeassistant/components/hive/icons.json new file mode 100644 index 00000000000000..671426f6253f1d --- /dev/null +++ b/homeassistant/components/hive/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "boost_heating_on": "mdi:radiator", + "boost_heating_off": "mdi:radiator-off", + "boost_hot_water": "mdi:water-boiler" + } +} diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index a340aee0764032..d173751c6c8e28 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -54,6 +54,7 @@ def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: self._attr_color_mode = ColorMode.COLOR_TEMP elif self.device["hiveType"] == "colourtuneablelight": self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} + self._attr_color_mode = ColorMode.UNKNOWN self._attr_min_mireds = 153 self._attr_max_mireds = 370 diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 2a92784a54e267..0849cf397823f5 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -8,7 +8,12 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfPower, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,6 +37,13 @@ device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, ), + SensorEntityDescription( + key="Current_Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + ), ) diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 01f695ad1a679c..6ea5f9d43db93b 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -43,7 +43,7 @@ async def validate_input(hass: HomeAssistant, user_input): """Validate the user input allows us to connect.""" try: client = await connect_client(hass, user_input) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise CannotConnect from err try: diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 234df9980357d6..5f78d9618109bb 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.43", "babel==2.13.1"] + "requirements": ["holidays==0.44", "babel==2.13.1"] } diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json new file mode 100644 index 00000000000000..48965cc554ae19 --- /dev/null +++ b/homeassistant/components/home_connect/icons.json @@ -0,0 +1,11 @@ +{ + "services": { + "start_program": "mdi:play", + "select_program": "mdi:form-select", + "pause_program": "mdi:pause", + "resume_program": "mdi:play-pause", + "set_option_active": "mdi:gesture-tap", + "set_option_selected": "mdi:gesture-tap", + "change_setting": "mdi:cog" + } +} diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e2a6fc1c9e7bc4..0b20f8698c2aa2 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -1,4 +1,10 @@ { + "config": { + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "step": {} + }, "issues": { "country_not_configured": { "title": "The country has not been configured", diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 036eb07e067ec9..f391b9907612ef 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -1,7 +1,6 @@ """The Home Assistant alerts integration.""" from __future__ import annotations -import asyncio import dataclasses from datetime import timedelta import logging @@ -53,7 +52,7 @@ async def async_update_alerts() -> None: f"https://alerts.home-assistant.io/alerts/{alert.alert_id}.json", timeout=aiohttp.ClientTimeout(total=30), ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Error fetching %s: timeout", alert.filename) continue diff --git a/homeassistant/components/homeassistant_green/__init__.py b/homeassistant/components/homeassistant_green/__init__.py index fbcd2093778efc..ed86723ab94ac6 100644 --- a/homeassistant/components/homeassistant_green/__init__.py +++ b/homeassistant/components/homeassistant_green/__init__.py @@ -1,7 +1,7 @@ """The Home Assistant Green integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Green config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/homeassistant_green/manifest.json b/homeassistant/components/homeassistant_green/manifest.json index 7c9dd0322ecb48..d543d562ee3611 100644 --- a/homeassistant/components/homeassistant_green/manifest.json +++ b/homeassistant/components/homeassistant_green/manifest.json @@ -1,9 +1,10 @@ { "domain": "homeassistant_green", "name": "Home Assistant Green", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio", "homeassistant_hardware"], + "dependencies": ["hardware", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_green", "integration_type": "hardware" } diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 218e0c3e88de61..4880d2e375f6b3 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -7,9 +7,10 @@ get_zigbee_socket, multi_pan_addon_using_device, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import discovery_flow from .const import DOMAIN from .util import get_usb_service_info @@ -51,9 +52,10 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: }, "radio_type": "ezsp", } - await hass.config_entries.flow.async_init( + discovery_flow.async_create_flow( + hass, "zha", - context={"source": "hardware"}, + context={"source": SOURCE_HARDWARE}, data=hw_discovery_data, ) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index b61e01061c3d9e..84ad464e779924 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,21 +1,27 @@ """The Home Assistant Yellow integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( check_multi_pan_addon, get_zigbee_socket, multi_pan_addon_using_device, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.helpers import discovery_flow from .const import RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Home Assistant Yellow config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady @@ -42,9 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "radio_type": "ezsp", } - await hass.config_entries.flow.async_init( + discovery_flow.async_create_flow( + hass, "zha", - context={"source": "hardware"}, + context={"source": SOURCE_HARDWARE}, data=hw_discovery_data, ) diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json index dd74df9295f289..a97150031721a0 100644 --- a/homeassistant/components/homeassistant_yellow/manifest.json +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -1,9 +1,10 @@ { "domain": "homeassistant_yellow", "name": "Home Assistant Yellow", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio", "homeassistant_hardware"], + "dependencies": ["hardware", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", "integration_type": "hardware" } diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 5812bc122c7ff9..a60f55e8bb0244 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -256,7 +256,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, context={"source": SOURCE_IMPORT}, data=conf, - ) + ), + eager_start=True, ) return True @@ -620,9 +621,7 @@ async def _async_reload_accessories_in_accessory_mode( self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - # Run must be awaited here since it may change - # the accessories hash - await new_acc.run() + new_acc.run() self._async_update_accessories_hash() def _async_remove_accessories_by_entity_id( @@ -675,9 +674,7 @@ async def _async_recreate_removed_accessories_in_bridge_mode( ) continue if acc := self.add_bridge_accessory(state): - # Run must be awaited here since it may change - # the accessories hash - await acc.run() + acc.run() self._async_update_accessories_hash() @callback @@ -752,7 +749,7 @@ def _would_exceed_max_devices(self, name: str | None) -> bool: return True return False - def add_bridge_triggers_accessory( + async def add_bridge_triggers_accessory( self, device: dr.DeviceEntry, device_triggers: list[dict[str, Any]] ) -> None: """Add device automation triggers to the bridge.""" @@ -767,18 +764,18 @@ def add_bridge_triggers_accessory( # the rest of the accessories from being created config: dict[str, Any] = {} self._fill_config_from_device_registry_entry(device, config) - self.bridge.add_accessory( - DeviceTriggerAccessory( - self.hass, - self.driver, - device.name, - None, - aid, - config, - device_id=device.id, - device_triggers=device_triggers, - ) + trigger_accessory = DeviceTriggerAccessory( + self.hass, + self.driver, + device.name, + None, + aid, + config, + device_id=device.id, + device_triggers=device_triggers, ) + await trigger_accessory.async_attach() + self.bridge.add_accessory(trigger_accessory) @callback def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: @@ -802,10 +799,11 @@ async def async_configure_accessories(self) -> list[State]: } ) - entity_states = [] + entity_states: list[State] = [] + entity_filter = self._filter.get_filter() for state in self.hass.states.async_all(): entity_id = state.entity_id - if not self._filter(entity_id): + if not entity_filter(entity_id): continue if ent_reg_ent := ent_reg.async_get(entity_id): @@ -1019,7 +1017,7 @@ async def _async_add_trigger_accessories(self) -> None: ) continue valid_device_triggers.append(trigger) - self.add_bridge_triggers_accessory(device, valid_device_triggers) + await self.add_bridge_triggers_accessory(device, valid_device_triggers) async def _async_create_accessories(self) -> bool: """Create the accessories.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 470bb78874c9ca..25b1c143f54a27 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -426,7 +426,9 @@ def available(self) -> bool: """Return if accessory is available.""" return self._available - async def run(self) -> None: + @ha_callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: """Handle accessory driver started event.""" if state := self.hass.states.get(self.entity_id): self.async_update_state_callback(state) @@ -608,7 +610,8 @@ def async_call_service( self.hass.async_create_task( self.hass.services.async_call( domain, service, service_data, context=context - ) + ), + eager_start=True, ) @ha_callback diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index a6984ae2121677..d7c8ea65e2d7ce 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -7,7 +7,7 @@ import random import re import string -from typing import Any +from typing import Any, Final, TypedDict import voluptuous as vol @@ -34,12 +34,6 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entityfilter import ( - CONF_EXCLUDE_DOMAINS, - CONF_EXCLUDE_ENTITIES, - CONF_INCLUDE_DOMAINS, - CONF_INCLUDE_ENTITIES, -) from homeassistant.loader import async_get_integrations from .const import ( @@ -69,13 +63,13 @@ INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] -DOMAINS_NEED_ACCESSORY_MODE = [ +DOMAINS_NEED_ACCESSORY_MODE = { CAMERA_DOMAIN, LOCK_DOMAIN, MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN, -] -NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] +} +NEVER_BRIDGED_DOMAINS = {CAMERA_DOMAIN} CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." @@ -124,12 +118,34 @@ "water_heater", ] -_EMPTY_ENTITY_FILTER: dict[str, list[str]] = { - CONF_INCLUDE_DOMAINS: [], - CONF_EXCLUDE_DOMAINS: [], - CONF_INCLUDE_ENTITIES: [], - CONF_EXCLUDE_ENTITIES: [], -} +CONF_INCLUDE_DOMAINS: Final = "include_domains" +CONF_INCLUDE_ENTITIES: Final = "include_entities" +CONF_EXCLUDE_DOMAINS: Final = "exclude_domains" +CONF_EXCLUDE_ENTITIES: Final = "exclude_entities" + + +class EntityFilterDict(TypedDict, total=False): + """Entity filter dict.""" + + include_domains: list[str] + include_entities: list[str] + exclude_domains: list[str] + exclude_entities: list[str] + + +def _make_entity_filter( + include_domains: list[str] | None = None, + include_entities: list[str] | None = None, + exclude_domains: list[str] | None = None, + exclude_entities: list[str] | None = None, +) -> EntityFilterDict: + """Create a filter dict.""" + return EntityFilterDict( + include_domains=include_domains or [], + include_entities=include_entities or [], + exclude_domains=exclude_domains or [], + exclude_entities=exclude_entities or [], + ) async def _async_domain_names(hass: HomeAssistant, domains: list[str]) -> str: @@ -141,19 +157,18 @@ async def _async_domain_names(hass: HomeAssistant, domains: list[str]) -> str: @callback -def _async_build_entites_filter( +def _async_build_entities_filter( domains: list[str], entities: list[str] -) -> dict[str, Any]: +) -> EntityFilterDict: """Build an entities filter from domains and entities.""" - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entity_filter[CONF_INCLUDE_ENTITIES] = entities # Include all of the domain if there are no entities # explicitly included as the user selected the domain - domains_with_entities_selected = _domains_set_from_entities(entities) - entity_filter[CONF_INCLUDE_DOMAINS] = [ - domain for domain in domains if domain not in domains_with_entities_selected - ] - return entity_filter + return _make_entity_filter( + include_domains=sorted( + set(domains).difference(_domains_set_from_entities(entities)) + ), + include_entities=entities, + ) def _async_cameras_from_entities(entities: list[str]) -> dict[str, str]: @@ -190,13 +205,15 @@ async def async_step_user( ) -> FlowResult: """Choose specific domains in bridge mode.""" if user_input is not None: - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entity_filter[CONF_INCLUDE_DOMAINS] = user_input[CONF_INCLUDE_DOMAINS] - self.hk_data[CONF_FILTER] = entity_filter + self.hk_data[CONF_FILTER] = _make_entity_filter( + include_domains=user_input[CONF_INCLUDE_DOMAINS] + ) return await self.async_step_pairing() self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE - default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + default_domains = ( + [] if self._async_current_entries(include_ignore=False) else DEFAULT_DOMAINS + ) name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="user", @@ -213,24 +230,28 @@ async def async_step_pairing( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Pairing instructions.""" + hk_data = self.hk_data + if user_input is not None: port = async_find_next_available_port(self.hass, DEFAULT_CONFIG_FLOW_PORT) await self._async_add_entries_for_accessory_mode_entities(port) - self.hk_data[CONF_PORT] = port - include_domains_filter = self.hk_data[CONF_FILTER][CONF_INCLUDE_DOMAINS] - for domain in NEVER_BRIDGED_DOMAINS: - if domain in include_domains_filter: - include_domains_filter.remove(domain) + hk_data[CONF_PORT] = port + conf_filter: EntityFilterDict = hk_data[CONF_FILTER] + conf_filter[CONF_INCLUDE_DOMAINS] = [ + domain + for domain in conf_filter[CONF_INCLUDE_DOMAINS] + if domain not in NEVER_BRIDGED_DOMAINS + ] return self.async_create_entry( - title=f"{self.hk_data[CONF_NAME]}:{self.hk_data[CONF_PORT]}", - data=self.hk_data, + title=f"{hk_data[CONF_NAME]}:{hk_data[CONF_PORT]}", + data=hk_data, ) - self.hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) - self.hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True + hk_data[CONF_NAME] = self._async_available_name(SHORT_BRIDGE_NAME) + hk_data[CONF_EXCLUDE_ACCESSORY_MODE] = True return self.async_show_form( step_id="pairing", - description_placeholders={CONF_NAME: self.hk_data[CONF_NAME]}, + description_placeholders={CONF_NAME: hk_data[CONF_NAME]}, ) async def _async_add_entries_for_accessory_mode_entities( @@ -265,14 +286,12 @@ async def async_step_accessory(self, accessory_input: dict[str, Any]) -> FlowRes state = self.hass.states.get(entity_id) assert state is not None name = state.attributes.get(ATTR_FRIENDLY_NAME) or state.entity_id - entity_filter = _EMPTY_ENTITY_FILTER.copy() - entity_filter[CONF_INCLUDE_ENTITIES] = [entity_id] entry_data = { CONF_PORT: port, CONF_NAME: self._async_available_name(name), CONF_HOMEKIT_MODE: HOMEKIT_MODE_ACCESSORY, - CONF_FILTER: entity_filter, + CONF_FILTER: _make_entity_filter(include_entities=[entity_id]), } if entity_id.startswith(CAMERA_ENTITY_PREFIX): entry_data[CONF_ENTITY_CONFIG] = { @@ -360,26 +379,19 @@ async def async_step_advanced( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose advanced options.""" - if ( - not self.show_advanced_options - or user_input is not None - or self.hk_options[CONF_HOMEKIT_MODE] != HOMEKIT_MODE_BRIDGE - ): - if user_input: - self.hk_options.update(user_input) - if ( - self.show_advanced_options - and self.hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE - ): - self.hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + hk_options = self.hk_options + show_advanced_options = self.show_advanced_options + bridge_mode = hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE - for key in (CONF_DOMAINS, CONF_ENTITIES): - if key in self.hk_options: - del self.hk_options[key] - - if CONF_INCLUDE_EXCLUDE_MODE in self.hk_options: - del self.hk_options[CONF_INCLUDE_EXCLUDE_MODE] + if not show_advanced_options or user_input is not None or not bridge_mode: + if user_input: + hk_options.update(user_input) + if show_advanced_options and bridge_mode: + hk_options[CONF_DEVICES] = user_input[CONF_DEVICES] + hk_options.pop(CONF_DOMAINS, None) + hk_options.pop(CONF_ENTITIES, None) + hk_options.pop(CONF_INCLUDE_EXCLUDE_MODE, None) return self.async_create_entry(title="", data=self.hk_options) all_supported_devices = await _async_get_supported_devices(self.hass) @@ -404,35 +416,37 @@ async def async_step_cameras( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose camera config.""" + hk_options = self.hk_options + all_entity_config: dict[str, dict[str, Any]] + if user_input is not None: - entity_config = self.hk_options[CONF_ENTITY_CONFIG] + all_entity_config = hk_options[CONF_ENTITY_CONFIG] for entity_id in self.included_cameras: + entity_config = all_entity_config.setdefault(entity_id, {}) + if entity_id in user_input[CONF_CAMERA_COPY]: - entity_config.setdefault(entity_id, {})[ - CONF_VIDEO_CODEC - ] = VIDEO_CODEC_COPY - elif ( - entity_id in entity_config - and CONF_VIDEO_CODEC in entity_config[entity_id] - ): - del entity_config[entity_id][CONF_VIDEO_CODEC] + entity_config[CONF_VIDEO_CODEC] = VIDEO_CODEC_COPY + elif CONF_VIDEO_CODEC in entity_config: + del entity_config[CONF_VIDEO_CODEC] + if entity_id in user_input[CONF_CAMERA_AUDIO]: - entity_config.setdefault(entity_id, {})[CONF_SUPPORT_AUDIO] = True - elif ( - entity_id in entity_config - and CONF_SUPPORT_AUDIO in entity_config[entity_id] - ): - del entity_config[entity_id][CONF_SUPPORT_AUDIO] + entity_config[CONF_SUPPORT_AUDIO] = True + elif CONF_SUPPORT_AUDIO in entity_config: + del entity_config[CONF_SUPPORT_AUDIO] + + if not entity_config: + all_entity_config.pop(entity_id) + return await self.async_step_advanced() cameras_with_audio = [] cameras_with_copy = [] - entity_config = self.hk_options.setdefault(CONF_ENTITY_CONFIG, {}) + all_entity_config = hk_options.setdefault(CONF_ENTITY_CONFIG, {}) for entity in self.included_cameras: - hk_entity_config = entity_config.get(entity, {}) - if hk_entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: + entity_config = all_entity_config.get(entity, {}) + if entity_config.get(CONF_VIDEO_CODEC) == VIDEO_CODEC_COPY: cameras_with_copy.append(entity) - if hk_entity_config.get(CONF_SUPPORT_AUDIO): + if entity_config.get(CONF_SUPPORT_AUDIO): cameras_with_audio.append(entity) data_schema = vol.Schema( @@ -453,18 +467,20 @@ async def async_step_accessory( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entity for the accessory.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] + entity_filter: EntityFilterDict if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter = _async_build_entites_filter(domains, entities) + entity_filter = _async_build_entities_filter(domains, entities) self.included_cameras = _async_cameras_from_entities(entities) - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = entity_filter if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True @@ -494,24 +510,21 @@ async def async_step_include( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entities to include from the domain on the bridge.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] if user_input is not None: entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter = _async_build_entites_filter(domains, entities) self.included_cameras = _async_cameras_from_entities(entities) - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = _async_build_entities_filter(domains, entities) if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter: EntityFilterDict = hk_options.get(CONF_FILTER, {}) entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) - all_supported_entities = _async_get_matching_entities( self.hass, domains, include_entity_category=True, include_hidden=True ) - if not entities: - entities = entity_filter.get(CONF_EXCLUDE_ENTITIES, []) # Strip out entities that no longer exist to prevent error in the UI default_value = [ entity_id for entity_id in entities if entity_id in all_supported_entities @@ -535,15 +548,13 @@ async def async_step_exclude( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Choose entities to exclude from the domain on the bridge.""" - domains = self.hk_options[CONF_DOMAINS] + hk_options = self.hk_options + domains = hk_options[CONF_DOMAINS] if user_input is not None: - entity_filter = deepcopy(_EMPTY_ENTITY_FILTER) - entities = cv.ensure_list(user_input[CONF_ENTITIES]) - entity_filter[CONF_INCLUDE_DOMAINS] = domains - entity_filter[CONF_EXCLUDE_ENTITIES] = entities self.included_cameras = {} - if CAMERA_DOMAIN in entity_filter[CONF_INCLUDE_DOMAINS]: + entities = cv.ensure_list(user_input[CONF_ENTITIES]) + if CAMERA_DOMAIN in domains: camera_entities = _async_get_matching_entities( self.hass, [CAMERA_DOMAIN] ) @@ -552,7 +563,9 @@ async def async_step_exclude( for entity_id in camera_entities if entity_id not in entities } - self.hk_options[CONF_FILTER] = entity_filter + hk_options[CONF_FILTER] = _make_entity_filter( + include_domains=domains, exclude_entities=entities + ) if self.included_cameras: return await self.async_step_cameras() return await self.async_step_advanced() @@ -600,14 +613,13 @@ async def async_step_init( self.hk_options = deepcopy(dict(self.config_entry.options)) homekit_mode = self.hk_options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE) - entity_filter = self.hk_options.get(CONF_FILTER, {}) + entity_filter: EntityFilterDict = self.hk_options.get(CONF_FILTER, {}) include_exclude_mode = MODE_INCLUDE entities = entity_filter.get(CONF_INCLUDE_ENTITIES, []) if homekit_mode != HOMEKIT_MODE_ACCESSORY: include_exclude_mode = MODE_INCLUDE if entities else MODE_EXCLUDE domains = entity_filter.get(CONF_INCLUDE_DOMAINS, []) - include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) - if include_entities: + if include_entities := entity_filter.get(CONF_INCLUDE_ENTITIES): domains.extend(_domains_set_from_entities(include_entities)) name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( @@ -708,7 +720,7 @@ def _async_get_entity_ids_for_accessory_mode( def _async_entity_ids_with_accessory_mode(hass: HomeAssistant) -> set[str]: """Return a set of entity ids that have config entries in accessory mode.""" - entity_ids = set() + entity_ids: set[str] = set() current_entries = hass.config_entries.async_entries(DOMAIN) for entry in current_entries: diff --git a/homeassistant/components/homekit/icons.json b/homeassistant/components/homekit/icons.json new file mode 100644 index 00000000000000..fb0461eb5d8415 --- /dev/null +++ b/homeassistant/components/homekit/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "reload": "mdi:reload", + "reset_accessory": "mdi:cog-refresh", + "unpair": "mdi:link-variant-off" + } +} diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index ed26265be24b0c..078ab8818acba2 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -11,6 +11,7 @@ Camera as PyhapCamera, ) from pyhap.const import CATEGORY_CAMERA +from pyhap.util import callback as pyhap_callback from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager @@ -251,7 +252,9 @@ def __init__( self._async_update_doorbell_state(state) - async def run(self) -> None: + @pyhap_callback # type: ignore[misc] + @callback + def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -276,7 +279,7 @@ async def run(self) -> None: ) ) - await super().run() + super().run() @callback def _async_update_motion_state_event( diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 1d60d405502ca3..47660e486f2694 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -9,6 +9,7 @@ CATEGORY_WINDOW_COVERING, ) from pyhap.service import Service +from pyhap.util import callback as pyhap_callback from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -125,7 +126,9 @@ def __init__(self, *args: Any) -> None: self.async_update_state(state) - async def run(self) -> None: + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -139,7 +142,7 @@ async def run(self) -> None: ) ) - await super().run() + super().run() @callback def _async_update_obstruction_event( diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 939c1bf37aea3d..0b2c965c7f3e66 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -3,6 +3,7 @@ from typing import Any from pyhap.const import CATEGORY_HUMIDIFIER +from pyhap.util import callback as pyhap_callback from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY, @@ -173,7 +174,9 @@ def __init__(self, *args: Any) -> None: if humidity_state := states.get(self.linked_humidity_sensor): self._async_update_current_humidity(humidity_state) - async def run(self) -> None: + @callback + @pyhap_callback # type: ignore[misc] + def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -187,7 +190,7 @@ async def run(self) -> None: ) ) - await super().run() + super().run() @callback def async_update_current_humidity_event( diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 4dcc6fb8f65546..c638da557643b2 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -839,8 +839,7 @@ def _get_temperature_range_from_state( # the max to appears to work, but less than 0 causes # a crash on the home app min_temp = max(min_temp, 0) - if min_temp > max_temp: - max_temp = min_temp + max_temp = max(max_temp, min_temp) return min_temp, max_temp diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index 8cd0163867946f..625ed0a4a44162 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -5,6 +5,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR +from pyhap.util import callback as pyhap_callback from homeassistant.core import CALLBACK_TYPE, Context, callback from homeassistant.helpers import entity_registry as er @@ -84,6 +85,30 @@ def __init__( serv_service_label.configure_char(CHAR_SERVICE_LABEL_NAMESPACE, value=1) serv_stateless_switch.add_linked_service(serv_service_label) + @callback + def _remove_triggers_if_configured(self) -> None: + if self._remove_triggers: + self._remove_triggers() + self._remove_triggers = None + + async def async_attach(self) -> None: + """Start the accessory.""" + self._remove_triggers_if_configured() + self._remove_triggers = await async_initialize_triggers( + self.hass, + self._device_triggers, + self.async_trigger, + "homekit", + self.display_name, + _LOGGER.log, + ) + + @pyhap_callback # type: ignore[misc] + @callback + def run(self) -> None: + """Run the accessory.""" + # Triggers have not entities so we do not call super().run() + async def async_trigger( self, run_variables: dict[str, Any], @@ -101,24 +126,10 @@ async def async_trigger( idx = int(run_variables["trigger"]["idx"]) self.triggers[idx].set_value(0) - # Attach the trigger using the helper in async run - # and detach it in async stop - async def run(self) -> None: - """Handle accessory driver started event.""" - self._remove_triggers = await async_initialize_triggers( - self.hass, - self._device_triggers, - self.async_trigger, - "homekit", - self.display_name, - _LOGGER.log, - ) - @callback def async_stop(self) -> None: """Handle accessory driver stop event.""" - if self._remove_triggers: - self._remove_triggers() + self._remove_triggers_if_configured() super().async_stop() @property diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ed9b8ca4622002..e3ff4d47fcff8e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -6,6 +6,11 @@ import logging import aiohomekit +from aiohomekit.const import ( + BLE_TRANSPORT_SUPPORTED, + COAP_TRANSPORT_SUPPORTED, + IP_TRANSPORT_SUPPORTED, +) from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -24,6 +29,15 @@ from .const import DOMAIN, KNOWN_DEVICES from .utils import async_get_controller +# Ensure all the controllers get imported in the executor +# since they are loaded late. +if BLE_TRANSPORT_SUPPORTED: + from aiohomekit.controller import ble # noqa: F401 +if COAP_TRANSPORT_SUPPORTED: + from aiohomekit.controller import coap # noqa: F401 +if IP_TRANSPORT_SUPPORTED: + from aiohomekit.controller import ip # noqa: F401 + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -43,13 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await conn.async_setup() except ( - asyncio.TimeoutError, + TimeoutError, AccessoryNotFoundError, EncryptionError, AccessoryDisconnectedError, ) as ex: del hass.data[KNOWN_DEVICES][conn.unique_id] - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): await conn.pairing.close() raise ConfigEntryNotReady from ex diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index a741cf549202d5..f1c2440ce9eb5d 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -70,7 +70,6 @@ def async_add_service(service: Service) -> bool: class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" - _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index 1c16b2c6483d63..a0c61578e66f5c 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging @@ -32,6 +33,7 @@ class HomeKitButtonEntityDescription(ButtonEntityDescription): """Describes Homekit button.""" + probe: Callable[[Characteristic], bool] | None = None write_value: int | str | None = None @@ -39,7 +41,7 @@ class HomeKitButtonEntityDescription(ButtonEntityDescription): CharacteristicsTypes.VENDOR_HAA_SETUP: HomeKitButtonEntityDescription( key=CharacteristicsTypes.VENDOR_HAA_SETUP, name="Setup", - icon="mdi:cog", + translation_key="setup", entity_category=EntityCategory.CONFIG, write_value="#HAA@trcmd", ), @@ -53,6 +55,7 @@ class HomeKitButtonEntityDescription(ButtonEntityDescription): CharacteristicsTypes.IDENTIFY: HomeKitButtonEntityDescription( key=CharacteristicsTypes.IDENTIFY, name="Identify", + device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, write_value=True, ), @@ -70,13 +73,19 @@ async def async_setup_entry( @callback def async_add_characteristic(char: Characteristic) -> bool: - entities: list[HomeKitButton | HomeKitEcobeeClearHoldButton] = [] + entities: list[CharacteristicEntity] = [] info = {"aid": char.service.accessory.aid, "iid": char.service.iid} if description := BUTTON_ENTITIES.get(char.type): entities.append(HomeKitButton(conn, info, char, description)) elif entity_type := BUTTON_ENTITY_CLASSES.get(char.type): entities.append(entity_type(conn, info, char)) + elif char.type == CharacteristicsTypes.THREAD_CONTROL_POINT: + if not conn.is_unprovisioned_thread_device: + return False + entities.append( + HomeKitProvisionPreferredThreadCredentials(conn, info, char) + ) else: return False @@ -91,7 +100,11 @@ def async_add_characteristic(char: Characteristic) -> bool: conn.add_char_factory(async_add_characteristic) -class HomeKitButton(CharacteristicEntity, ButtonEntity): +class BaseHomeKitButton(CharacteristicEntity, ButtonEntity): + """Base class for all HomeKit buttons.""" + + +class HomeKitButton(BaseHomeKitButton): """Representation of a Button control on a homekit accessory.""" entity_description: HomeKitButtonEntityDescription @@ -125,7 +138,7 @@ async def async_press(self) -> None: await self.async_put_characteristics({key: val}) -class HomeKitEcobeeClearHoldButton(CharacteristicEntity, ButtonEntity): +class HomeKitEcobeeClearHoldButton(BaseHomeKitButton): """Representation of a Button control for Ecobee clear hold request.""" def get_characteristic_types(self) -> list[str]: @@ -154,7 +167,7 @@ async def async_press(self) -> None: await self.async_put_characteristics({key: val}) -class HomeKitProvisionPreferredThreadCredentials(CharacteristicEntity, ButtonEntity): +class HomeKitProvisionPreferredThreadCredentials(BaseHomeKitButton): """A button users can press to migrate their HomeKit BLE device to Thread.""" _attr_entity_category = EntityCategory.CONFIG @@ -178,5 +191,4 @@ async def async_press(self) -> None: BUTTON_ENTITY_CLASSES: dict[str, type] = { CharacteristicsTypes.VENDOR_ECOBEE_CLEAR_HOLD: HomeKitEcobeeClearHoldButton, - CharacteristicsTypes.THREAD_CONTROL_POINT: HomeKitProvisionPreferredThreadCredentials, } diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index c127c6dd95ebcf..0dabc814a7ef59 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -18,7 +18,7 @@ EncryptionError, ) from aiohomekit.model import Accessories, Accessory, Transport -from aiohomekit.model.characteristics import Characteristic +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.thread.dataset_store import async_get_preferred_dataset @@ -30,6 +30,7 @@ from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.util.async_ import create_eager_task from .config_flow import normalize_hkid from .const import ( @@ -46,6 +47,7 @@ SUBSCRIBE_COOLDOWN, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry +from .utils import IidTuple, unique_id_to_iids RETRY_INTERVAL = 60 # seconds MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE = 3 @@ -329,12 +331,19 @@ def _async_start_polling(self) -> None: self.config_entry.async_on_unload( async_track_time_interval( self.hass, - self.async_request_update, + self._async_schedule_update, self.pairing.poll_interval, name=f"HomeKit Device {self.unique_id} availability check poll", ) ) + @callback + def _async_schedule_update(self, now: datetime) -> None: + """Schedule an update.""" + self.hass.async_create_task( + self._debounced_update.async_call(), eager_start=True + ) + async def async_add_new_entities(self) -> None: """Add new entities to Home Assistant.""" await self.async_load_platforms() @@ -513,6 +522,58 @@ def async_remove_legacy_device_serial_numbers(self) -> None: device_registry.async_update_device(device.id, new_identifiers=identifiers) + @callback + def async_reap_stale_entity_registry_entries(self) -> None: + """Delete entity registry entities for removed characteristics, services and accessories.""" + _LOGGER.debug( + "Removing stale entity registry entries for pairing %s", + self.unique_id, + ) + + reg = er.async_get(self.hass) + + # For the current config entry only, visit all registry entity entries + # Build a set of (unique_id, aid, sid, iid) + # For services, (unique_id, aid, sid, None) + # For accessories, (unique_id, aid, None, None) + entries = er.async_entries_for_config_entry(reg, self.config_entry.entry_id) + existing_entities = { + iids: entry.entity_id + for entry in entries + if (iids := unique_id_to_iids(entry.unique_id)) + } + + # Process current entity map and produce a similar set + current_unique_id: set[IidTuple] = set() + for accessory in self.entity_map.accessories: + current_unique_id.add((accessory.aid, None, None)) + + for service in accessory.services: + current_unique_id.add((accessory.aid, service.iid, None)) + + for char in service.characteristics: + if self.pairing.transport != Transport.BLE: + if char.type == CharacteristicsTypes.THREAD_CONTROL_POINT: + continue + + current_unique_id.add( + ( + accessory.aid, + service.iid, + char.iid, + ) + ) + + # Remove the difference + if stale := existing_entities.keys() - current_unique_id: + for parts in stale: + _LOGGER.debug( + "Removing stale entity registry entry %s for pairing %s", + existing_entities[parts], + self.unique_id, + ) + reg.async_remove(existing_entities[parts]) + @callback def async_migrate_ble_unique_id(self) -> None: """Config entries from step_bluetooth used incorrect identifier for unique_id.""" @@ -615,6 +676,8 @@ async def async_process_entity_map(self) -> None: self.async_migrate_ble_unique_id() + self.async_reap_stale_entity_registry_entries() + self.async_create_devices() # Load any triggers for this config entry @@ -630,7 +693,9 @@ async def async_unload(self) -> None: def process_config_changed(self, config_num: int) -> None: """Handle a config change notification from the pairing.""" - self.hass.async_create_task(self.async_update_new_accessories_state()) + self.hass.async_create_task( + self.async_update_new_accessories_state(), eager_start=True + ) async def async_update_new_accessories_state(self) -> None: """Process a change in the pairings accessories state.""" @@ -758,7 +823,10 @@ async def async_load_platforms(self) -> None: if to_load: await asyncio.gather( - *[self.async_load_platform(platform) for platform in to_load] + *( + create_eager_task(self.async_load_platform(platform)) + for platform in to_load + ) ) @callback diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index cc2c28cb5dcf33..aea5a6661eea48 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -1,5 +1,4 @@ """Constants for the homekit_controller component.""" -import asyncio from aiohomekit.exceptions import ( AccessoryDisconnectedError, @@ -56,6 +55,7 @@ ServicesTypes.DOORBELL: "event", ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH: "event", ServicesTypes.SERVICE_LABEL: "event", + ServicesTypes.AIR_PURIFIER: "fan", } CHARACTERISTIC_PLATFORMS = { @@ -105,10 +105,12 @@ CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor", CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch", CharacteristicsTypes.TEMPERATURE_UNITS: "select", + CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT: "sensor", + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: "select", } STARTUP_EXCEPTIONS = ( - asyncio.TimeoutError, + TimeoutError, AccessoryNotFoundError, EncryptionError, AccessoryDisconnectedError, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index d87b6ab3e399c7..1b2d572f2b6ff8 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -206,6 +206,7 @@ class HomeKitFanV2(BaseHomeKitFan): ENTITY_TYPES = { ServicesTypes.FAN: HomeKitFanV1, ServicesTypes.FAN_V2: HomeKitFanV2, + ServicesTypes.AIR_PURIFIER: HomeKitFanV2, } diff --git a/homeassistant/components/homekit_controller/icons.json b/homeassistant/components/homekit_controller/icons.json new file mode 100644 index 00000000000000..aeee8dd670ab48 --- /dev/null +++ b/homeassistant/components/homekit_controller/icons.json @@ -0,0 +1,53 @@ +{ + "entity": { + "button": { + "setup": { + "default": "mdi:cog" + } + }, + "number": { + "spray_quantity": { + "default": "mdi:water" + }, + "elevation": { + "default": "mdi:elevation-rise" + }, + "volume": { + "default": "mdi:volume-high" + }, + "duration": { + "default": "mdi:timer" + }, + "sensitivity": { + "default": "mdi:knob" + } + }, + "select": { + "temperature_display_units": { + "default": "mdi:thermometer" + } + }, + "sensor": { + "valve_position": { + "default": "mdi:pipe-valve" + } + }, + "switch": { + "pairing_mode": { + "default": "mdi:lock-open" + }, + "lock_physical_controls": { + "default": "mdi:lock-open" + }, + "mute": { + "default": "mdi:volume-mute" + }, + "sleep_mode": { + "default": "mdi:power-sleep" + }, + "valve": { + "default": "mdi:water" + } + } + } +} diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 1617b907a26a47..22d78123e0a65b 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -12,8 +12,9 @@ "config_flow": true, "dependencies": ["bluetooth_adapters", "zeroconf"], "documentation": "https://www.home-assistant.io/integrations/homekit_controller", + "import_executor": true, "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.1.4"], + "requirements": ["aiohomekit==3.1.5"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index c453efb821905c..e2d856126da615 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -28,37 +28,37 @@ CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_VOCOLINC_HUMIDIFIER_SPRAY_LEVEL, name="Spray Quantity", - icon="mdi:water", + translation_key="spray_quantity", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_DEGREE_ELEVATION, name="Elevation", - icon="mdi:elevation-rise", + translation_key="elevation", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_AQARA_GATEWAY_VOLUME: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_AQARA_GATEWAY_VOLUME, name="Volume", - icon="mdi:volume-high", + translation_key="volume", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_AQARA_E1_GATEWAY_VOLUME: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_AQARA_E1_GATEWAY_VOLUME, name="Volume", - icon="mdi:volume-high", + translation_key="volume", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_MOTION_DURATION, name="Duration", - icon="mdi:timer", + translation_key="duration", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY: NumberEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_MOTION_SENSITIVITY, name="Sensitivity", - icon="mdi:knob", + translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index e6eae1c51ca87d..be1a73133011f5 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -5,7 +5,10 @@ from enum import IntEnum from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes -from aiohomekit.model.characteristics.const import TemperatureDisplayUnits +from aiohomekit.model.characteristics.const import ( + TargetAirPurifierStateValues, + TemperatureDisplayUnits, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -40,13 +43,22 @@ class HomeKitSelectEntityDescription( key="temperature_display_units", translation_key="temperature_display_units", name="Temperature Display Units", - icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, choices={ "celsius": TemperatureDisplayUnits.CELSIUS, "fahrenheit": TemperatureDisplayUnits.FAHRENHEIT, }, ), + CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET: HomeKitSelectEntityDescription( + key="air_purifier_state_target", + translation_key="air_purifier_state_target", + name="Air Purifier Mode", + entity_category=EntityCategory.CONFIG, + choices={ + "automatic": TargetAirPurifierStateValues.AUTOMATIC, + "manual": TargetAirPurifierStateValues.MANUAL, + }, + ), } _ECOBEE_MODE_TO_TEXT = { diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index ebfba110e48bbb..28bb0cd309c537 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -3,10 +3,15 @@ from collections.abc import Callable from dataclasses import dataclass +from enum import IntEnum from aiohomekit.model import Accessory, Transport from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes -from aiohomekit.model.characteristics.const import ThreadNodeCapabilities, ThreadStatus +from aiohomekit.model.characteristics.const import ( + CurrentAirPurifierStateValues, + ThreadNodeCapabilities, + ThreadStatus, +) from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.bluetooth import ( @@ -52,6 +57,7 @@ class HomeKitSensorEntityDescription(SensorEntityDescription): probe: Callable[[Characteristic], bool] | None = None format: Callable[[Characteristic], str] | None = None + enum: dict[IntEnum, str] | None = None def thread_node_capability_to_str(char: Characteristic) -> str: @@ -324,6 +330,18 @@ def thread_status_to_str(char: Characteristic) -> str: ], translation_key="thread_status", ), + CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT: HomeKitSensorEntityDescription( + key=CharacteristicsTypes.AIR_PURIFIER_STATE_CURRENT, + name="Air Purifier Status", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + enum={ + CurrentAirPurifierStateValues.INACTIVE: "inactive", + CurrentAirPurifierStateValues.IDLE: "idle", + CurrentAirPurifierStateValues.ACTIVE: "purifying", + }, + translation_key="air_purifier_state_current", + ), CharacteristicsTypes.VENDOR_NETATMO_NOISE: HomeKitSensorEntityDescription( key=CharacteristicsTypes.VENDOR_NETATMO_NOISE, name="Noise", @@ -340,7 +358,7 @@ def thread_status_to_str(char: Characteristic) -> str: CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION: HomeKitSensorEntityDescription( key=CharacteristicsTypes.VENDOR_EVE_THERMO_VALVE_POSITION, name="Valve position", - icon="mdi:pipe-valve", + translation_key="valve_position", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -502,7 +520,7 @@ def is_low_battery(self) -> bool: @property def is_charging(self) -> bool: - """Return true if currently charing.""" + """Return true if currently charging.""" # 0 = not charging # 1 = charging # 2 = not chargeable @@ -535,6 +553,8 @@ def __init__( ) -> None: """Initialise a secondary HomeKit characteristic sensor.""" self.entity_description = description + if self.entity_description.enum: + self._attr_options = list(self.entity_description.enum.values()) super().__init__(conn, info, char) def get_characteristic_types(self) -> list[str]: @@ -551,10 +571,11 @@ def name(self) -> str: @property def native_value(self) -> str | int | float: """Return the current sensor value.""" - val = self._char.value + if self.entity_description.enum: + return self.entity_description.enum[self._char.value] if self.entity_description.format: - return self.entity_description.format(val) - return val + return self.entity_description.format(self._char) + return self._char.value ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 998c375aac1892..d1205645fd3809 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -108,6 +108,12 @@ "celsius": "Celsius", "fahrenheit": "Fahrenheit" } + }, + "air_purifier_state_target": { + "state": { + "automatic": "Automatic", + "manual": "Manual" + } } }, "sensor": { @@ -131,6 +137,13 @@ "leader": "Leader", "router": "Router" } + }, + "air_purifier_state_current": { + "state": { + "inactive": "Inactive", + "idle": "Idle", + "purifying": "Purifying" + } } } } diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 2ae19152b93ac0..b7e1b27ef7f8c0 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -42,31 +42,31 @@ class DeclarativeSwitchEntityDescription(SwitchEntityDescription): CharacteristicsTypes.VENDOR_AQARA_PAIRING_MODE: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.VENDOR_AQARA_PAIRING_MODE, name="Pairing Mode", - icon="mdi:lock-open", + translation_key="pairing_mode", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.VENDOR_AQARA_E1_PAIRING_MODE, name="Pairing Mode", - icon="mdi:lock-open", + translation_key="pairing_mode", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.LOCK_PHYSICAL_CONTROLS: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.LOCK_PHYSICAL_CONTROLS, name="Lock Physical Controls", - icon="mdi:lock-open", + translation_key="lock_physical_controls", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.MUTE: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.MUTE, name="Mute", - icon="mdi:volume-mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, ), CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: DeclarativeSwitchEntityDescription( key=CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE, name="Sleep Mode", - icon="mdi:power-sleep", + translation_key="sleep_mode", entity_category=EntityCategory.CONFIG, ), } @@ -104,6 +104,8 @@ def extra_state_attributes(self) -> dict[str, Any] | None: class HomeKitValve(HomeKitEntity, SwitchEntity): """Represents a valve in an irrigation system.""" + _attr_translation_key = "valve" + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -121,11 +123,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the specified valve off.""" await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: False}) - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:water" - @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index 33a085047244c2..489dee5584c899 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -11,6 +11,31 @@ from .const import CONTROLLER from .storage import async_get_entity_storage +IidTuple = tuple[int, int | None, int | None] + + +def unique_id_to_iids(unique_id: str) -> IidTuple | None: + """Convert a unique_id to a tuple of accessory id, service iid and characteristic iid. + + Depending on the field in the accessory map that is referenced, some of these may be None. + + Returns None if this unique_id doesn't follow the homekit_controller scheme and is invalid. + """ + try: + match unique_id.split("_"): + case (unique_id, aid, sid, cid): + return (int(aid), int(sid), int(cid)) + case (unique_id, aid, sid): + return (int(aid), int(sid), None) + case (unique_id, aid): + return (int(aid), None, None) + except ValueError: + # One of the int conversions failed - this can't be a valid homekit_controller unique id + # Fall through and return None + pass + + return None + @lru_cache def folded_name(name: str) -> str: diff --git a/homeassistant/components/homematicip_cloud/icons.json b/homeassistant/components/homematicip_cloud/icons.json new file mode 100644 index 00000000000000..2e9f6158c357bc --- /dev/null +++ b/homeassistant/components/homematicip_cloud/icons.json @@ -0,0 +1,12 @@ +{ + "services": { + "activate_eco_mode_with_duration": "mdi:leaf", + "activate_eco_mode_with_period": "mdi:leaf", + "activate_vacation": "mdi:compass", + "deactivate_eco_mode": "mdi:leaf-off", + "deactivate_vacation": "mdi:compass-off", + "set_active_climate_profile": "mdi:home-thermometer", + "dump_hap_config": "mdi:database-export", + "reset_energy_counter": "mdi:reload" + } +} diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d75ca02b66f4f1..580a0f637c1ac2 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.16"] + "requirements": ["homematicip==1.1.0"] } diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 65919033801fc7..2b5f2f01cd3e3b 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -69,16 +69,15 @@ async def async_setup_entry( elif isinstance(device, AsyncOpenCollector8Module): for channel in range(1, 9): entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) - elif isinstance(device, AsyncHeatingSwitch2): - for channel in range(1, 3): - entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) - elif isinstance(device, AsyncMultiIOBox): - for channel in range(1, 3): - entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) - elif isinstance(device, AsyncPrintedCircuitBoardSwitch2): - for channel in range(1, 3): - entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) - elif isinstance(device, AsyncBrandSwitch2): + elif isinstance( + device, + ( + AsyncBrandSwitch2, + AsyncPrintedCircuitBoardSwitch2, + AsyncHeatingSwitch2, + AsyncMultiIOBox, + ), + ): for channel in range(1, 3): entities.append(HomematicipMultiSwitch(hap, device, channel=channel)) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 2db140d5fe96b0..149d5b891f4c28 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==4.3.0"], + "requirements": ["python-homewizard-energy==4.3.1"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index baabf4ca4d8796..f58db72a07e268 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1,5 +1,4 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" -import asyncio from dataclasses import dataclass import aiosomecomfort @@ -68,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b aiosomecomfort.device.ConnectionError, aiosomecomfort.device.ConnectionTimeout, aiosomecomfort.device.SomeComfortError, - asyncio.TimeoutError, + TimeoutError, ) as ex: raise ConfigEntryNotReady( "Failed to initialize the Honeywell client: Connection error" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 6bc6169c68c9ff..fb8537ce36bdfa 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -1,7 +1,6 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" from __future__ import annotations -import asyncio import datetime from typing import Any @@ -357,7 +356,7 @@ async def _set_temperature(self, **kwargs) -> None: else: if mode == "cool": await self._device.set_setpoint_cool(temperature) - if mode == "heat": + if mode in ["heat", "emheat"]: await self._device.set_setpoint_heat(temperature) except UnexpectedResponse as err: @@ -506,7 +505,7 @@ async def _login() -> None: await self._device.refresh() except ( - asyncio.TimeoutError, + TimeoutError, AscConnectionError, APIRateLimited, AuthError, @@ -525,9 +524,8 @@ async def _login() -> None: except UnauthorizedError: await _login() return - except ( - asyncio.TimeoutError, + TimeoutError, AscConnectionError, APIRateLimited, ClientConnectionError, diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 43d08ee2294638..aeb72899e1158a 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the honeywell integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any @@ -61,7 +60,7 @@ async def async_step_reauth_confirm( except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" @@ -93,7 +92,7 @@ async def async_step_user(self, user_input=None) -> FlowResult: except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json index 962afef0d907d0..378a9ac186569a 100644 --- a/homeassistant/components/hp_ilo/manifest.json +++ b/homeassistant/components/hp_ilo/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/hp_ilo", "iot_class": "local_polling", - "requirements": ["python-hpilo==4.3"] + "requirements": ["python-hpilo==4.4.3"] } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 6bb0c154540081..ab228e32a52bff 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -32,6 +32,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.http import ( + KEY_AUTHENTICATED, # noqa: F401 + HomeAssistantView, + current_request, +) from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -41,20 +46,14 @@ from .auth import async_setup_auth from .ban import setup_bans -from .const import ( # noqa: F401 - KEY_AUTHENTICATED, - KEY_HASS, - KEY_HASS_REFRESH_TOKEN_ID, - KEY_HASS_USER, -) +from .const import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401 from .cors import setup_cors from .decorators import require_admin # noqa: F401 from .forwarded import async_setup_forwarded from .headers import setup_headers -from .request_context import current_request, setup_request_context +from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .view import HomeAssistantView from .web_runner import HomeAssistantTCPSite DOMAIN: Final = "http" diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 99d38bf582edc2..640d899924e3d6 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -20,13 +20,13 @@ from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER -from .request_context import current_request _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 62569495ba70f7..0b720b078b989a 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -15,7 +15,6 @@ from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized import voluptuous as vol -from homeassistant.components import persistent_notification from homeassistant.config import load_yaml_config_file from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -128,6 +127,10 @@ async def process_wrong_login(request: Request) -> None: _LOGGER.warning(log_msg) + # Circular import with websocket_api + # pylint: disable=import-outside-toplevel + from homeassistant.components import persistent_notification + persistent_notification.async_create( hass, notification_msg, "Login attempt failed", NOTIFICATION_ID_LOGIN ) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index df27122b64a9e5..090e5234aebd22 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,7 +1,8 @@ """HTTP specific constants.""" from typing import Final -KEY_AUTHENTICATED: Final = "ha_authenticated" +from homeassistant.helpers.http import KEY_AUTHENTICATED # noqa: F401 + KEY_HASS: Final = "hass" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" diff --git a/homeassistant/components/http/request_context.py b/homeassistant/components/http/request_context.py index 6e036b9cdc8dbf..b516b63dc5c928 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -7,10 +7,7 @@ from aiohttp.web import Application, Request, StreamResponse, middleware from homeassistant.core import callback - -current_request: ContextVar[Request | None] = ContextVar( - "current_request", default=None -) +from homeassistant.helpers.http import current_request # noqa: F401 @callback diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 1be3d761a3b215..ce02879dbb37c9 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -1,180 +1,7 @@ """Support for views.""" from __future__ import annotations -import asyncio -from collections.abc import Awaitable, Callable -from http import HTTPStatus -import logging -from typing import Any - -from aiohttp import web -from aiohttp.typedefs import LooseHeaders -from aiohttp.web_exceptions import ( - HTTPBadRequest, - HTTPInternalServerError, - HTTPUnauthorized, -) -from aiohttp.web_urldispatcher import AbstractRoute -import voluptuous as vol - -from homeassistant import exceptions -from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, HomeAssistant, is_callback -from homeassistant.helpers.json import ( - find_paths_unserializable_data, - json_bytes, - json_dumps, +from homeassistant.helpers.http import ( # noqa: F401 + HomeAssistantView, + request_handler_factory, ) -from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data - -from .const import KEY_AUTHENTICATED - -_LOGGER = logging.getLogger(__name__) - - -class HomeAssistantView: - """Base view for all views.""" - - url: str | None = None - extra_urls: list[str] = [] - # Views inheriting from this class can override this - requires_auth = True - cors_allowed = False - - @staticmethod - def context(request: web.Request) -> Context: - """Generate a context from a request.""" - if (user := request.get("hass_user")) is None: - return Context() - - return Context(user_id=user.id) - - @staticmethod - def json( - result: Any, - status_code: HTTPStatus | int = HTTPStatus.OK, - headers: LooseHeaders | None = None, - ) -> web.Response: - """Return a JSON response.""" - try: - msg = json_bytes(result) - except JSON_ENCODE_EXCEPTIONS as err: - _LOGGER.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(result, dump=json_dumps) - ), - ) - raise HTTPInternalServerError from err - response = web.Response( - body=msg, - content_type=CONTENT_TYPE_JSON, - status=int(status_code), - headers=headers, - zlib_executor_size=32768, - ) - response.enable_compression() - return response - - def json_message( - self, - message: str, - status_code: HTTPStatus | int = HTTPStatus.OK, - message_code: str | None = None, - headers: LooseHeaders | None = None, - ) -> web.Response: - """Return a JSON message response.""" - data = {"message": message} - if message_code is not None: - data["code"] = message_code - return self.json(data, status_code, headers=headers) - - def register( - self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher - ) -> None: - """Register the view with a router.""" - assert self.url is not None, "No url set for view" - urls = [self.url] + self.extra_urls - routes: list[AbstractRoute] = [] - - for method in ("get", "post", "delete", "put", "patch", "head", "options"): - if not (handler := getattr(self, method, None)): - continue - - handler = request_handler_factory(hass, self, handler) - - for url in urls: - routes.append(router.add_route(method, url, handler)) - - # Use `get` because CORS middleware is not be loaded in emulated_hue - if self.cors_allowed: - allow_cors = app.get("allow_all_cors") - else: - allow_cors = app.get("allow_configured_cors") - - if allow_cors: - for route in routes: - allow_cors(route) - - -def request_handler_factory( - hass: HomeAssistant, view: HomeAssistantView, handler: Callable -) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: - """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) - assert is_coroutinefunction or is_callback( - handler - ), "Handler should be a coroutine or a callback." - - async def handle(request: web.Request) -> web.StreamResponse: - """Handle incoming request.""" - if hass.is_stopping: - return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) - - authenticated = request.get(KEY_AUTHENTICATED, False) - - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Serving %s to %s (auth: %s)", - request.path, - request.remote, - authenticated, - ) - - try: - if is_coroutinefunction: - result = await handler(request, **request.match_info) - else: - result = handler(request, **request.match_info) - except vol.Invalid as err: - raise HTTPBadRequest() from err - except exceptions.ServiceNotFound as err: - raise HTTPInternalServerError() from err - except exceptions.Unauthorized as err: - raise HTTPUnauthorized() from err - - if isinstance(result, web.StreamResponse): - # The method handler returned a ready-made Response, how nice of it - return result - - status_code = HTTPStatus.OK - if isinstance(result, tuple): - result, status_code = result - - if isinstance(result, bytes): - return web.Response(body=result, status=status_code) - - if isinstance(result, str): - return web.Response(text=result, status=status_code) - - if result is None: - return web.Response(body=b"", status=status_code) - - raise TypeError( - f"Result should be None, string, bytes or StreamResponse. Got: {result}" - ) - - return handle diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 29c59d3ff9c244..81be4e462d10b7 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -557,14 +557,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> recipient = options.get(CONF_RECIPIENT) if isinstance(recipient, str): options[CONF_RECIPIENT] = [x.strip() for x in recipient.split(",")] - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry(config_entry, options=options, version=2) _LOGGER.info("Migrated config entry to version %d", config_entry.version) if config_entry.version == 2: - config_entry.version = 3 data = dict(config_entry.data) data[CONF_MAC] = [] - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=3) _LOGGER.info("Migrated config entry to version %d", config_entry.version) # There can be no longer needed *_from_yaml data and options things left behind # from pre-2022.4ish; they can be removed while at it when/if we eventually bump and diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 7f709b02dc22ab..d4fa0b6db6ff9c 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -125,11 +125,6 @@ def assumed_state(self) -> bool: ConnectionStatusEnum.DISCONNECTED, ) - @property - def icon(self) -> str: - """Return mobile connectivity sensor icon.""" - return "mdi:signal" if self.is_on else "mdi:signal-off" - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Get additional attributes related to connection status.""" @@ -154,11 +149,6 @@ def assumed_state(self) -> bool: """Return True if real state is assumed, not known.""" return self._raw_state is None - @property - def icon(self) -> str: - """Return WiFi status sensor icon.""" - return "mdi:wifi" if self.is_on else "mdi:wifi-off" - class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" @@ -204,8 +194,3 @@ def is_on(self) -> bool: def assumed_state(self) -> bool: """Return True if real state is assumed, not known.""" return self._raw_state is None - - @property - def icon(self) -> str: - """Return WiFi status sensor icon.""" - return "mdi:email-alert" if self.is_on else "mdi:email-off" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index fd1b9850054d1c..1bb5077a2b4b5c 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -70,11 +70,10 @@ async def async_setup_entry( track_wired_clients = router.config_entry.options.get( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ) - for entity in registry.entities.values(): - if ( - entity.domain == DEVICE_TRACKER_DOMAIN - and entity.config_entry_id == config_entry.entry_id - ): + for entity in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity.domain == DEVICE_TRACKER_DOMAIN: mac = entity.unique_id.partition("-")[2] # Do not add known wired clients if not tracking them (any more) skip = False diff --git a/homeassistant/components/huawei_lte/icons.json b/homeassistant/components/huawei_lte/icons.json new file mode 100644 index 00000000000000..d105702bf51821 --- /dev/null +++ b/homeassistant/components/huawei_lte/icons.json @@ -0,0 +1,59 @@ +{ + "entity": { + "binary_sensor": { + "mobile_connection": { + "default": "mdi:signal-off", + "state": { + "on": "mdi:signal" + } + }, + "wifi_status": { + "default": "mdi:wifi-off", + "state": { + "on": "mdi:wifi" + } + }, + "24ghz_wifi_status": { + "default": "mdi:wifi-off", + "state": { + "on": "mdi:wifi" + } + }, + "5ghz_wifi_status": { + "default": "mdi:wifi-off", + "state": { + "on": "mdi:wifi" + } + }, + "sms_storage_full": { + "default": "mdi:email-off", + "state": { + "on": "mdi:email-alert" + } + } + }, + "select": { + "preferred_network_mode": { + "default": "mdi:transmission-tower" + } + }, + "switch": { + "mobile_data": { + "default": "mdi:signal-off", + "state": { + "on": "mdi:signal" + } + }, + "wifi_guest_network": { + "default": "mdi:wifi-off", + "state": { + "on": "mdi:wifi" + } + } + } + }, + "services": { + "resume_integration": "mdi:play-pause", + "suspend_integration": "mdi:pause" + } +} diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index f211da3c2e8c50..6fef2d745cb533 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -50,7 +50,6 @@ async def async_setup_entry( desc = HuaweiSelectEntityDescription( key=KEY_NET_NET_MODE, entity_category=EntityCategory.CONFIG, - icon="mdi:transmission-tower", name="Preferred network mode", translation_key="preferred_network_mode", options=[ diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 651099be42d6bb..3743716390e781 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -107,11 +107,6 @@ def _turn(self, state: bool) -> None: self._raw_state = str(value) self.schedule_update_ha_state() - @property - def icon(self) -> str: - """Return switch icon.""" - return "mdi:signal" if self.is_on else "mdi:signal-off" - class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" @@ -135,11 +130,6 @@ def _turn(self, state: bool) -> None: self._raw_state = "1" if state else "0" self.schedule_update_ha_state() - @property - def icon(self) -> str: - """Return switch icon.""" - return "mdi:wifi" if self.is_on else "mdi:wifi-off" - @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c5ceebec3f83e4..abf91cf45779d5 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -83,7 +83,7 @@ async def async_initialize_bridge(self) -> bool: create_config_flow(self.hass, self.host) return False except ( - asyncio.TimeoutError, + TimeoutError, client_exceptions.ClientOSError, client_exceptions.ServerDisconnectedError, client_exceptions.ContentTypeError, diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 7262dea39ef750..a1345cf3bba2e3 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -111,7 +111,7 @@ async def async_step_init( bridges = await discover_nupnp( websession=aiohttp_client.async_get_clientsession(self.hass) ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="discover_timeout") if bridges: diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 4cd6ca143cbf79..e8d214da3c8e48 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.7.0"], + "requirements": ["aiohue==4.7.1"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index f1bcd0bbbe35fb..4707302d288234 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations +from functools import partial from typing import TypeAlias from aiohue.v2 import HueBridgeV2 @@ -58,14 +59,15 @@ async def async_setup_entry( @callback def register_items(controller: ControllerType, sensor_class: SensorType): + make_binary_sensor_entity = partial(sensor_class, bridge, controller) + @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Binary Sensor.""" - async_add_entities([sensor_class(bridge, controller, resource)]) + async_add_entities([make_binary_sensor_entity(resource)]) # add all current items in controller - for sensor in controller: - async_add_sensor(EventType.RESOURCE_ADDED, sensor) + async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller) # register listener for new sensors config_entry.async_on_unload( diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 348d60d8de2393..bbf5dc9c19fd8c 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -1,6 +1,7 @@ """Support for Hue lights.""" from __future__ import annotations +from functools import partial from typing import Any from aiohue import HueBridgeV2 @@ -21,6 +22,7 @@ LightEntity, LightEntityDescription, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -50,17 +52,15 @@ async def async_setup_entry( bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] api: HueBridgeV2 = bridge.api controller: LightsController = api.lights + make_light_entity = partial(HueLight, bridge, controller) @callback def async_add_light(event_type: EventType, resource: Light) -> None: """Add Hue Light.""" - light = HueLight(bridge, controller, resource) - async_add_entities([light]) + async_add_entities([make_light_entity(resource)]) # add all current items in controller - for light in controller: - async_add_light(EventType.RESOURCE_ADDED, resource=light) - + async_add_entities(make_light_entity(light) for light in controller) # register listener for new lights config_entry.async_on_unload( controller.subscribe(async_add_light, event_filter=EventType.RESOURCE_ADDED) @@ -70,6 +70,7 @@ def async_add_light(event_type: EventType, resource: Light) -> None: class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" + _fixed_color_mode: ColorMode | None = None entity_description = LightEntityDescription( key="hue_light", has_entity_name=True, name=None ) @@ -83,17 +84,20 @@ def __init__( self._attr_supported_features |= LightEntityFeature.FLASH self.resource = resource self.controller = controller - self._supported_color_modes: set[ColorMode | str] = set() + supported_color_modes = {ColorMode.ONOFF} if self.resource.supports_color: - self._supported_color_modes.add(ColorMode.XY) + supported_color_modes.add(ColorMode.XY) if self.resource.supports_color_temperature: - self._supported_color_modes.add(ColorMode.COLOR_TEMP) + supported_color_modes.add(ColorMode.COLOR_TEMP) if self.resource.supports_dimming: - if len(self._supported_color_modes) == 0: - # only add color mode brightness if no color variants - self._supported_color_modes.add(ColorMode.BRIGHTNESS) + supported_color_modes.add(ColorMode.BRIGHTNESS) # support transition if brightness control self._attr_supported_features |= LightEntityFeature.TRANSITION + supported_color_modes = filter_supported_color_modes(supported_color_modes) + self._attr_supported_color_modes = supported_color_modes + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) self._last_brightness: float | None = None self._color_temp_active: bool = False # get list of supported effects (combine effects and timed_effects) @@ -128,14 +132,15 @@ def is_on(self) -> bool: @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode + + # The light supports both color temperature and XY, determine which + # mode the light is in if self.color_temp_active: return ColorMode.COLOR_TEMP - if self.resource.supports_color: - return ColorMode.XY - if self.resource.supports_dimming: - return ColorMode.BRIGHTNESS - # fallback to on_off - return ColorMode.ONOFF + return ColorMode.XY @property def color_temp_active(self) -> bool: @@ -180,11 +185,6 @@ def max_mireds(self) -> int: # return a fallback value to prevent issues with mired->kelvin conversions return FALLBACK_MAX_MIREDS - @property - def supported_color_modes(self) -> set | None: - """Flag supported features.""" - return self._supported_color_modes - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the optional state attributes.""" diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 56f708e2dfd128..59dc8de2975677 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -1,6 +1,7 @@ """Support for Hue sensors.""" from __future__ import annotations +from functools import partial from typing import Any, TypeAlias from aiohue.v2 import HueBridgeV2 @@ -53,14 +54,15 @@ async def async_setup_entry( @callback def register_items(controller: ControllerType, sensor_class: SensorType): + make_sensor_entity = partial(sensor_class, bridge, controller) + @callback def async_add_sensor(event_type: EventType, resource: SensorType) -> None: """Add Hue Sensor.""" - async_add_entities([sensor_class(bridge, controller, resource)]) + async_add_entities([make_sensor_entity(resource)]) # add all current items in controller - for sensor in controller: - async_add_sensor(EventType.RESOURCE_ADDED, sensor) + async_add_entities(make_sensor_entity(sensor) for sensor in controller) # register listener for new sensors config_entry.async_on_unload( diff --git a/homeassistant/components/huisbaasje/__init__.py b/homeassistant/components/huisbaasje/__init__.py index b1c2d865e0cff0..9ea4b547596d0c 100644 --- a/homeassistant/components/huisbaasje/__init__.py +++ b/homeassistant/components/huisbaasje/__init__.py @@ -84,7 +84,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_huisbaasje(energyflip: EnergyFlip) -> dict[str, dict[str, Any]]: """Update the data by performing a request to Huisbaasje.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(FETCH_TIMEOUT): if not energyflip.is_authenticated(): diff --git a/homeassistant/components/huisbaasje/icons.json b/homeassistant/components/huisbaasje/icons.json new file mode 100644 index 00000000000000..403e757bf2ba06 --- /dev/null +++ b/homeassistant/components/huisbaasje/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "sensor": { + "current_gas": { + "default": "mdi:meter-gas" + }, + "gas_today": { + "default": "mdi:meter-gas" + }, + "gas_week": { + "default": "mdi:meter-gas" + }, + "gas_month": { + "default": "mdi:meter-gas" + }, + "gas_year": { + "default": "mdi:meter-gas" + } + } + } +} diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 2c1d2ffde68c38..f07711268d57c6 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -64,7 +64,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, key=SOURCE_TYPE_ELECTRICITY, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="current_power_peak", @@ -73,7 +72,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, key=SOURCE_TYPE_ELECTRICITY_IN, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="current_power_off_peak", @@ -82,7 +80,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, key=SOURCE_TYPE_ELECTRICITY_IN_LOW, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="current_power_out_peak", @@ -91,7 +88,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, key=SOURCE_TYPE_ELECTRICITY_OUT, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="current_power_out_off_peak", @@ -100,7 +96,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfPower.WATT, key=SOURCE_TYPE_ELECTRICITY_OUT_LOW, state_class=SensorStateClass.MEASUREMENT, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_consumption_peak_today", @@ -110,7 +105,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, precision=3, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_consumption_off_peak_today", @@ -120,7 +114,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, precision=3, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_production_peak_today", @@ -130,7 +123,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, precision=3, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_production_off_peak_today", @@ -140,7 +132,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, precision=3, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_today", @@ -150,7 +141,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_week", @@ -160,7 +150,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_month", @@ -170,7 +159,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="energy_year", @@ -180,7 +168,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, - icon="mdi:lightning-bolt", ), HuisbaasjeSensorEntityDescription( translation_key="current_gas", @@ -188,7 +175,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, - icon="mdi:fire", precision=1, ), HuisbaasjeSensorEntityDescription( @@ -198,7 +184,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:counter", precision=1, ), HuisbaasjeSensorEntityDescription( @@ -208,7 +193,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:counter", precision=1, ), HuisbaasjeSensorEntityDescription( @@ -218,7 +202,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:counter", precision=1, ), HuisbaasjeSensorEntityDescription( @@ -228,7 +211,6 @@ class HuisbaasjeSensorEntityDescription(SensorEntityDescription): key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, state_class=SensorStateClass.TOTAL_INCREASING, - icon="mdi:counter", precision=1, ), ] diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 56ebbe6fb26431..4156dcdafae62e 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -3,40 +3,23 @@ import logging from aiopvapi.helpers.aiorequest import AioRequest -from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.tools import base64_to_unicode +from aiopvapi.hub import Hub +from aiopvapi.resources.model import PowerviewData from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes from aiopvapi.shades import Shades -from aiopvapi.userdata import UserData from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from .const import ( - API_PATH_FWVERSION, - DEFAULT_LEGACY_MAINPROCESSOR, - DOMAIN, - FIRMWARE, - FIRMWARE_MAINPROCESSOR, - FIRMWARE_NAME, - HUB_EXCEPTIONS, - HUB_NAME, - MAC_ADDRESS_IN_USERDATA, - ROOM_DATA, - SCENE_DATA, - SERIAL_NUMBER_IN_USERDATA, - SHADE_DATA, - USER_DATA, -) +from .const import DOMAIN, HUB_EXCEPTIONS from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo, PowerviewEntryData from .shade_data import PowerviewShadeData -from .util import async_map_data_by_id PARALLEL_UPDATES = 1 @@ -45,6 +28,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.COVER, + Platform.NUMBER, Platform.SCENE, Platform.SELECT, Platform.SENSOR, @@ -58,46 +42,70 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data hub_address = config[CONF_HOST] + api_version = config.get(CONF_API_VERSION, None) + _LOGGER.debug("Connecting %s at %s with v%s api", DOMAIN, hub_address, api_version) + websession = async_get_clientsession(hass) - pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) + pv_request = AioRequest( + hub_address, loop=hass.loop, websession=websession, api_version=api_version + ) try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) + except HUB_EXCEPTIONS as err: + raise ConfigEntryNotReady( + f"Connection error to PowerView hub {hub_address}: {err}" + ) from err + + if hub.role != "Primary": + # this should be caught in config_flow, but account for a hub changing roles + # this will only happen manually by a user + _LOGGER.error( + "%s (%s) is performing role of %s Hub. " + "Only the Primary Hub can manage shades", + hub.name, + hub.hub_address, + hub.role, + ) + return False + try: async with asyncio.timeout(10): rooms = Rooms(pv_request) - room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - + room_data: PowerviewData = await rooms.get_rooms() async with asyncio.timeout(10): scenes = Scenes(pv_request) - scene_data = async_map_data_by_id( - (await scenes.get_resources())[SCENE_DATA] - ) - + scene_data: PowerviewData = await scenes.get_scenes() async with asyncio.timeout(10): shades = Shades(pv_request) - shade_entries = await shades.get_resources() - shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) - + shade_data: PowerviewData = await shades.get_shades() except HUB_EXCEPTIONS as err: raise ConfigEntryNotReady( - f"Connection error to PowerView hub: {hub_address}: {err}" + f"Connection error to PowerView hub {hub_address}: {err}" ) from err + if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + if CONF_API_VERSION not in config: + new_data = {**entry.data} + new_data[CONF_API_VERSION] = hub.api_version + hass.config_entries.async_update_entry(entry, data=new_data) + + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub) coordinator.async_set_updated_data(PowerviewShadeData()) # populate raw shade data into the coordinator for diagnostics - coordinator.data.store_group_data(shade_entries[SHADE_DATA]) + coordinator.data.store_group_data(shade_data) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData( api=pv_request, - room_data=room_data, - scene_data=scene_data, - shade_data=shade_data, + room_data=room_data.processed, + scene_data=scene_data.processed, + shade_data=shade_data.processed, coordinator=coordinator, device_info=device_info, ) @@ -107,39 +115,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_get_device_info( - pv_request: AioRequest, hub_address: str -) -> PowerviewDeviceInfo: +async def async_get_device_info(hub: Hub) -> PowerviewDeviceInfo: """Determine device info.""" - userdata = UserData(pv_request) - resources = await userdata.get_resources() - userdata_data = resources[USER_DATA] - - if FIRMWARE in userdata_data: - main_processor_info = userdata_data[FIRMWARE][FIRMWARE_MAINPROCESSOR] - elif userdata_data: - # Legacy devices - fwversion = ApiEntryPoint(pv_request, API_PATH_FWVERSION) - resources = await fwversion.get_resources() - - if FIRMWARE in resources: - main_processor_info = resources[FIRMWARE][FIRMWARE_MAINPROCESSOR] - else: - main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR - return PowerviewDeviceInfo( - name=base64_to_unicode(userdata_data[HUB_NAME]), - mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], - serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], - firmware=main_processor_info, - model=main_processor_info[FIRMWARE_NAME], - hub_address=hub_address, + name=hub.name, + mac_address=hub.mac_address, + serial_number=hub.serial_number, + firmware=hub.firmware, + model=hub.model, + hub_address=hub.ip, ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index cb6bc72954f374..c37741fcb0917e 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -5,7 +5,14 @@ from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ( + ATTR_NAME, + MOTION_CALIBRATE, + MOTION_FAVORITE, + MOTION_JOG, +) +from aiopvapi.hub import Hub +from aiopvapi.resources.shade import BaseShade from homeassistant.components.button import ( ButtonDeviceClass, @@ -17,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -27,7 +34,8 @@ class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" - press_action: Callable[[BaseShade], Any] + press_action: Callable[[BaseShade | Hub], Any] + create_entity_fn: Callable[[BaseShade | Hub], bool] @dataclass(frozen=True) @@ -37,18 +45,20 @@ class PowerviewButtonDescription( """Class to describe a Button entity.""" -BUTTONS: Final = [ +BUTTONS_SHADE: Final = [ PowerviewButtonDescription( key="calibrate", translation_key="calibrate", icon="mdi:swap-vertical-circle-outline", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_CALIBRATE), press_action=lambda shade: shade.calibrate(), ), PowerviewButtonDescription( key="identify", device_class=ButtonDeviceClass.IDENTIFY, entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_JOG), press_action=lambda shade: shade.jog(), ), PowerviewButtonDescription( @@ -56,6 +66,7 @@ class PowerviewButtonDescription( translation_key="favorite", icon="mdi:heart", entity_category=EntityCategory.DIAGNOSTIC, + create_entity_fn=lambda shade: shade.is_supported(MOTION_FAVORITE), press_action=lambda shade: shade.favorite(), ), ] @@ -71,28 +82,25 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[ButtonEntity] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - - for description in BUTTONS: - entities.append( - PowerviewButton( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") + for description in BUTTONS_SHADE: + if description.create_entity_fn(shade): + entities.append( + PowerviewShadeButton( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) -class PowerviewButton(ShadeEntity, ButtonEntity): +class PowerviewShadeButton(ShadeEntity, ButtonEntity): """Representation of an advanced feature button.""" def __init__( diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index 81532187bbf5fe..97e04b7d522504 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,14 +3,15 @@ import asyncio import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.hub import Hub import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp, zeroconf -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -19,9 +20,9 @@ _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) HAP_SUFFIX = "._hap._tcp.local." -POWERVIEW_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G2_SUFFIX = "._powerview._tcp.local." +POWERVIEW_G3_SUFFIX = "._powerview-g3._tcp.local." async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str, str]: @@ -36,44 +37,70 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str try: async with asyncio.timeout(10): - device_info = await async_get_device_info(pv_request, hub_address) + hub = Hub(pv_request) + await hub.query_firmware() + device_info = await async_get_device_info(hub) except HUB_EXCEPTIONS as err: raise CannotConnect from err + if hub.role != "Primary": + raise UnsupportedDevice( + f"{hub.name} ({hub.hub_address}) is the {hub.role} Hub. " + "Only the Primary can manage shades" + ) + + _LOGGER.debug("Connection made using api version: %s", hub.api_version) + # Return info that you want to store in the config entry. return { "title": device_info.name, "unique_id": device_info.serial_number, + CONF_API_VERSION: hub.api_version, } -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class PowerviewConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Hunter Douglas PowerView.""" VERSION = 1 def __init__(self) -> None: """Initialize the powerview config flow.""" - self.powerview_config: dict[str, str] = {} + self.powerview_config: dict = {} self.discovered_ip: str | None = None self.discovered_name: str | None = None + self.data_schema: dict = {vol.Required(CONF_HOST): str} async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors: dict[str, Any] = {} + errors: dict[str, str] = {} + if user_input is not None: info, error = await self._async_validate_or_error(user_input[CONF_HOST]) + if info and not error: + self.powerview_config = { + CONF_HOST: user_input[CONF_HOST], + CONF_NAME: info["title"], + CONF_API_VERSION: info[CONF_API_VERSION], + } await self.async_set_unique_id(info["unique_id"]) return self.async_create_entry( - title=info["title"], data={CONF_HOST: user_input[CONF_HOST]} + title=self.powerview_config[CONF_NAME], + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) + + if TYPE_CHECKING: + assert error is not None errors["base"] = error return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=vol.Schema(self.data_schema), errors=errors ) async def _async_validate_or_error( @@ -85,6 +112,8 @@ async def _async_validate_or_error( info = await validate_input(self.hass, host) except CannotConnect: return None, "cannot_connect" + except UnsupportedDevice: + return None, "unsupported_device" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") return None, "unknown" @@ -102,7 +131,8 @@ async def async_step_zeroconf( ) -> FlowResult: """Handle zeroconf discovery.""" self.discovered_ip = discovery_info.host - name = discovery_info.name.removesuffix(POWERVIEW_SUFFIX) + name = discovery_info.name.removesuffix(POWERVIEW_G2_SUFFIX) + name = name.removesuffix(POWERVIEW_G3_SUFFIX) self.discovered_name = name return await self.async_step_discovery_confirm() @@ -119,7 +149,7 @@ async def async_step_discovery_confirm(self) -> FlowResult: """Confirm dhcp or homekit discovery.""" # If we already have the host configured do # not open connections to it if we can avoid it. - assert self.discovered_ip and self.discovered_name + assert self.discovered_ip and self.discovered_name is not None self.context[CONF_HOST] = self.discovered_ip for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self.discovered_ip: @@ -129,14 +159,19 @@ async def async_step_discovery_confirm(self) -> FlowResult: info, error = await self._async_validate_or_error(self.discovered_ip) if error: return self.async_abort(reason=error) - assert info is not None + + api_version = info[CONF_API_VERSION] + if not self.discovered_name: + self.discovered_name = f"Powerview Generation {api_version}" + await self.async_set_unique_id(info["unique_id"], raise_on_progress=False) self._abort_if_unique_id_configured({CONF_HOST: self.discovered_ip}) self.powerview_config = { CONF_HOST: self.discovered_ip, CONF_NAME: self.discovered_name, + CONF_API_VERSION: api_version, } return await self.async_step_link() @@ -147,7 +182,10 @@ async def async_step_link( if user_input is not None: return self.async_create_entry( title=self.powerview_config[CONF_NAME], - data={CONF_HOST: self.powerview_config[CONF_HOST]}, + data={ + CONF_HOST: self.powerview_config[CONF_HOST], + CONF_API_VERSION: self.powerview_config[CONF_API_VERSION], + }, ) self._set_confirm_only() @@ -159,3 +197,7 @@ async def async_step_link( class CannotConnect(exceptions.HomeAssistantError): """Error to indicate we cannot connect.""" + + +class UnsupportedDevice(exceptions.HomeAssistantError): + """Error to indicate the device is not supported.""" diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 7dd4c229c486f7..a2d18c6f5128ed 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -1,92 +1,28 @@ -"""Support for Powerview scenes from a Powerview hub.""" +"""Constants for Hunter Douglas Powerview hub.""" -import asyncio from aiohttp.client_exceptions import ServerDisconnectedError -from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatusError +from aiopvapi.helpers.aiorequest import ( + PvApiConnectionError, + PvApiEmptyData, + PvApiMaintenance, + PvApiResponseStatusError, +) DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" -HUB_ADDRESS = "address" - -SCENE_DATA = "sceneData" -SHADE_DATA = "shadeData" -ROOM_DATA = "roomData" -USER_DATA = "userData" - -MAC_ADDRESS_IN_USERDATA = "macAddress" -SERIAL_NUMBER_IN_USERDATA = "serialNumber" -HUB_NAME = "hubName" - -FIRMWARE = "firmware" -FIRMWARE_MAINPROCESSOR = "mainProcessor" -FIRMWARE_NAME = "name" -FIRMWARE_REVISION = "revision" -FIRMWARE_SUB_REVISION = "subRevision" -FIRMWARE_BUILD = "build" - REDACT_MAC_ADDRESS = "mac_address" REDACT_SERIAL_NUMBER = "serial_number" REDACT_HUB_ADDRESS = "hub_address" -SCENE_NAME = "name" -SCENE_ID = "id" -ROOM_ID_IN_SCENE = "roomId" - -SHADE_NAME = "name" -SHADE_ID = "id" -ROOM_ID_IN_SHADE = "roomId" - -ROOM_NAME = "name" -ROOM_NAME_UNICODE = "name_unicode" -ROOM_ID = "id" - -SHADE_BATTERY_LEVEL = "batteryStrength" -SHADE_BATTERY_LEVEL_MAX = 200 - -ATTR_SIGNAL_STRENGTH = "signalStrength" -ATTR_SIGNAL_STRENGTH_MAX = 4 - -STATE_ATTRIBUTE_ROOM_NAME = "roomName" +STATE_ATTRIBUTE_ROOM_NAME = "room_name" HUB_EXCEPTIONS = ( ServerDisconnectedError, - asyncio.TimeoutError, + TimeoutError, PvApiConnectionError, PvApiResponseStatusError, + PvApiMaintenance, + PvApiEmptyData, ) - -LEGACY_DEVICE_SUB_REVISION = 1 -LEGACY_DEVICE_REVISION = 0 -LEGACY_DEVICE_BUILD = 0 -LEGACY_DEVICE_MODEL = "PowerView Hub" - -DEFAULT_LEGACY_MAINPROCESSOR = { - FIRMWARE_REVISION: LEGACY_DEVICE_REVISION, - FIRMWARE_SUB_REVISION: LEGACY_DEVICE_SUB_REVISION, - FIRMWARE_BUILD: LEGACY_DEVICE_BUILD, - FIRMWARE_NAME: LEGACY_DEVICE_MODEL, -} - -API_PATH_FWVERSION = "api/fwversion" - -POS_KIND_NONE = 0 -POS_KIND_PRIMARY = 1 -POS_KIND_SECONDARY = 2 -POS_KIND_VANE = 3 -POS_KIND_ERROR = 4 - - -ATTR_BATTERY_KIND = "batteryKind" -BATTERY_KIND_HARDWIRED = 1 -BATTERY_KIND_BATTERY = 2 -BATTERY_KIND_RECHARGABLE = 3 - -POWER_SUPPLY_TYPE_MAP = { - BATTERY_KIND_HARDWIRED: "Hardwired Power Supply", - BATTERY_KIND_BATTERY: "Battery Wand", - BATTERY_KIND_RECHARGABLE: "Rechargeable Battery", -} -POWER_SUPPLY_TYPE_REVERSE_MAP = {v: k for k, v in POWER_SUPPLY_TYPE_MAP.items()} diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index 4643536d56d6e0..db4079f2b58eaa 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -5,12 +5,14 @@ from datetime import timedelta import logging +from aiopvapi.helpers.aiorequest import PvApiMaintenance +from aiopvapi.hub import Hub from aiopvapi.shades import Shades from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SHADE_DATA +from .const import HUB_EXCEPTIONS from .shade_data import PowerviewShadeData _LOGGER = logging.getLogger(__name__) @@ -19,18 +21,14 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): """DataUpdateCoordinator to gather data from a powerview hub.""" - def __init__( - self, - hass: HomeAssistant, - shades: Shades, - hub_address: str, - ) -> None: + def __init__(self, hass: HomeAssistant, shades: Shades, hub: Hub) -> None: """Initialize DataUpdateCoordinator to gather data for specific Powerview Hub.""" self.shades = shades + self.hub = hub super().__init__( hass, _LOGGER, - name=f"powerview hub {hub_address}", + name=f"powerview hub {hub.hub_address}", update_interval=timedelta(seconds=60), ) @@ -38,17 +36,20 @@ async def _async_update_data(self) -> PowerviewShadeData: """Fetch data from shade endpoint.""" async with asyncio.timeout(10): - shade_entries = await self.shades.get_resources() - - if isinstance(shade_entries, bool): - # hub returns boolean on a 204/423 empty response (maintenance) - # continual polling results in inevitable error - raise UpdateFailed("Powerview Hub is undergoing maintenance") + try: + shade_entries = await self.shades.get_shades() + except PvApiMaintenance as error: + # hub is undergoing maintenance, pause polling + raise UpdateFailed(error) from error + except HUB_EXCEPTIONS as error: + raise UpdateFailed( + f"Powerview Hub {self.hub.hub_address} did not return any data: {error}" + ) from error if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data") + raise UpdateFailed("No new shade data was returned") # only update if shade_entries is valid - self.data.store_group_data(shade_entries[SHADE_DATA]) + self.data.store_group_data(shade_entries) return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 6d050bc1dbd5e5..5b998b697a4b3b 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -4,21 +4,20 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress +from dataclasses import replace from datetime import datetime, timedelta import logging from math import ceil from typing import Any from aiopvapi.helpers.constants import ( - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, + ATTR_NAME, + CLOSED_POSITION, MAX_POSITION, MIN_POSITION, + MOTION_STOP, ) -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.resources.shade import BaseShade, ShadePosition from homeassistant.components.cover import ( ATTR_POSITION, @@ -32,20 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from .const import ( - DOMAIN, - LEGACY_DEVICE_MODEL, - POS_KIND_PRIMARY, - POS_KIND_SECONDARY, - POS_KIND_VANE, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - STATE_ATTRIBUTE_ROOM_NAME, -) +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -57,14 +46,6 @@ RESYNC_DELAY = 60 -# this equates to 0.75/100 in terms of hass blind position -# some blinds in a closed position report less than 655.35 (1%) -# but larger than 0 even though they are clearly closed -# Find 1 percent of MAX_POSITION, then find 75% of that number -# The means currently 491.5125 or less is closed position -# implemented for top/down shades, but also works fine with normal shades -CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) - SCAN_INTERVAL = timedelta(minutes=10) @@ -76,41 +57,40 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator + async def _async_initial_refresh() -> None: + """Force position refresh shortly after adding. + + Legacy shades can become out of sync with hub when moved + using physical remotes. This also allows reducing speed + of calls to older generation hubs in an effort to + prevent hub crashes. + """ + + for shade in pv_entry.shade_data.values(): + with suppress(TimeoutError): + # hold off to avoid spamming the hub + async with asyncio.timeout(10): + _LOGGER.debug("Initial refresh of shade: %s", shade.name) + await shade.refresh() + entities: list[ShadeEntity] = [] - for raw_shade in pv_entry.shade_data.values(): - # The shade may be out of sync with the hub - # so we force a refresh when we add it if possible - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - with suppress(asyncio.TimeoutError): - async with asyncio.timeout(1): - await shade.refresh() - - if ATTR_POSITION_DATA not in shade.raw_data: - _LOGGER.info( - "The %s shade was skipped because it is missing position data", - name_before_refresh, - ) - continue - coordinator.data.update_shade_positions(shade.raw_data) - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + for shade in pv_entry.shade_data.values(): + coordinator.data.update_shade_position(shade.id, shade.current_position) + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") entities.extend( create_powerview_shade_entity( - coordinator, pv_entry.device_info, room_name, shade, name_before_refresh + coordinator, pv_entry.device_info, room_name, shade, shade.name ) ) - async_add_entities(entities) - - -def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hunter douglas position to hass position.""" - return round((hd_position / max_val) * 100) + async_add_entities(entities) -def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: - """Convert hass position to hunter douglas position.""" - return int(hass_position / 100 * max_val) + # background the fetching of state for initial launch + entry.async_create_background_task( + hass, + _async_initial_refresh(), + f"powerview {entry.title} initial shade refresh", + ) class PowerViewShadeBase(ShadeEntity, CoverEntity): @@ -135,7 +115,7 @@ def __init__( super().__init__(coordinator, device_info, room_name, shade, name) self._shade: BaseShade = shade self._scheduled_transition_update: CALLBACK_TYPE | None = None - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync: Callable[[], None] | None = None @@ -172,22 +152,22 @@ def is_closed(self) -> bool: @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass(self.positions.primary, MAX_POSITION) + return self.positions.primary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position, {}) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position, {}) + return replace(self._shade.close_position, velocity=self.positions.velocity) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" @@ -208,12 +188,12 @@ async def async_open_cover(self, **kwargs: Any) -> None: async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._async_cancel_scheduled_transition_update() - self.data.update_from_response(await self._shade.stop()) + await self._shade.stop() await self._async_force_refresh_state() @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" + """Don't allow a cover to go into an impossbile position.""" # no override required in base return target_hass_position @@ -222,21 +202,21 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: await self._async_set_cover_position(kwargs[ATTR_POSITION]) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_one = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) - async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + async def _async_execute_move(self, move: ShadePosition) -> None: """Execute a move that can affect multiple positions.""" - response = await self._shade.move(move.request) - # Process any positions we know will update as result - # of the request since the hub won't return them - for kind, position in move.new_positions.items(): - self.data.update_shade_position(self._shade.id, position, kind) - # Finally process the response - self.data.update_from_response(response) + _LOGGER.debug("Move request %s: %s", self.name, move) + response = await self._shade.move(move) + _LOGGER.debug("Move response %s: %s", self.name, response) + + # Process the response from the hub (including new positions) + self.data.update_shade_position(self._shade.id, response) async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" @@ -251,9 +231,9 @@ async def _async_set_cover_position(self, target_hass_position: int) -> None: self.async_write_ha_state() @callback - def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: + def _async_update_shade_data(self, shade_data: ShadePosition) -> None: """Update the current cover position from the data.""" - self.data.update_shade_positions(shade_data) + self.data.update_shade_position(self._shade.id, shade_data) self._attr_is_opening = False self._attr_is_closing = False @@ -283,7 +263,7 @@ def _async_schedule_update_for_transition(self, steps: int) -> None: est_time_to_complete_transition, ) - # Schedule an forced update for when we expect the transition + # Schedule a forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -342,8 +322,12 @@ async def async_update(self) -> None: # The update will likely timeout and # error if are already have one in flight return - await self._shade.refresh() - self._async_update_shade_data(self._shade.raw_data) + # suppress timeouts caused by hub nightly reboot + with suppress(TimeoutError): + async with asyncio.timeout(10): + await self._shade.refresh() + _LOGGER.debug("Process update %s: %s", self.name, self._shade.current_position) + self._async_update_shade_data(self._shade.current_position) class PowerViewShade(PowerViewShadeBase): @@ -372,31 +356,31 @@ def __init__( | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @property def current_cover_tilt_position(self) -> int: """Return the current cover tile position.""" - return hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.tilt @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + return self.positions.primary + self.positions.tilt @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.open_position_tilt, {}) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove(self._shade.close_position_tilt, {}) + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity + ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" @@ -411,13 +395,13 @@ async def async_open_cover_tilt(self, **kwargs: Any) -> None: self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) async def _async_set_cover_tilt_position( self, target_hass_tilt_position: int ) -> None: - """Move the vane to a specific position.""" + """Move the tilt to a specific position.""" final_position = self.current_cover_position + target_hass_tilt_position self._async_schedule_update_for_transition( abs(self.transition_steps - final_position) @@ -426,11 +410,19 @@ async def _async_set_cover_tilt_position( self.async_write_ha_state() @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, {} + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: @@ -450,49 +442,25 @@ class PowerViewShadeWithTiltOnClosed(PowerViewShadeWithTiltBase): _attr_name = None @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.open_position, velocity=self.positions.velocity) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} - ) + return replace(self._shade.close_position, velocity=self.positions.velocity) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the close tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.close_position_tilt, {POS_KIND_PRIMARY: MIN_POSITION} - ) - - @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = hass_position_to_hd(target_hass_position) - return PowerviewShadeMove( - {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, - {POS_KIND_VANE: MIN_POSITION}, - ) - - @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, - {POS_KIND_PRIMARY: MIN_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -506,32 +474,21 @@ class PowerViewShadeWithTiltAnywhere(PowerViewShadeWithTiltBase): """ @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - position_vane = self.positions.vane - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + tilt=self.positions.tilt, + velocity=self.positions.velocity, ) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_shade = self.positions.primary - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSITION2: position_vane, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_VANE, - }, - {}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @@ -558,7 +515,7 @@ def __init__( | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -577,17 +534,18 @@ class PowerViewShadeTopDown(PowerViewShadeBase): @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(MAX_POSITION - self.positions.primary, MAX_POSITION) + # inverted positioning + return MAX_POSITION - self.positions.primary + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(MAX_POSITION - kwargs[ATTR_POSITION]) @property def is_closed(self) -> bool: """Return if the cover is closed.""" return (MAX_POSITION - self.positions.primary) <= CLOSED_POSITION - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the shade to a specific position.""" - await self._async_set_cover_position(100 - kwargs[ATTR_POSITION]) - class PowerViewShadeDualRailBase(PowerViewShadeBase): """Representation of a shade with top/down bottom/up capabilities. @@ -600,9 +558,7 @@ class PowerViewShadeDualRailBase(PowerViewShadeBase): @property def transition_steps(self) -> int: """Return the steps to make a move.""" - return hd_position_to_hass( - self.positions.primary, MAX_POSITION - ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.primary + self.positions.secondary class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @@ -629,22 +585,16 @@ def __init__( @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: - """Dont allow a cover to go into an impossbile position.""" - cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) - return min(target_hass_position, (100 - cover_top)) + """Don't allow a cover to go into an impossbile position.""" + return min(target_hass_position, (MAX_POSITION - self.positions.secondary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = hass_position_to_hd(target_hass_position) - position_top = self.positions.secondary - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + secondary=self.positions.secondary, + velocity=self.positions.velocity, ) @@ -689,41 +639,31 @@ def is_closed(self) -> bool: def current_cover_position(self) -> int: """Return the current position of cover.""" # these need to be inverted to report state correctly in HA - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" # these shades share a class in parent API # override open position for top shade - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSITION2: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + return ShadePosition( + primary=MIN_POSITION, + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @callback def _clamp_cover_limit(self, target_hass_position: int) -> int: """Don't allow a cover to go into an impossbile position.""" - cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) - return min(target_hass_position, (100 - cover_bottom)) + return min(target_hass_position, (MAX_POSITION - self.positions.primary)) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_bottom = self.positions.primary - position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_bottom, - ATTR_POSITION2: position_top, - ATTR_POSKIND1: POS_KIND_PRIMARY, - ATTR_POSKIND2: POS_KIND_SECONDARY, - }, - {}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=self.positions.primary, + secondary=target_hass_position, + velocity=self.positions.velocity, ) @@ -739,33 +679,27 @@ def transition_steps(self) -> int: # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + secondary = self.positions.secondary / 2 return ceil(primary + secondary) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MAX_POSITION, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -782,7 +716,6 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): _attr_translation_key = "combined" - # type def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, @@ -806,36 +739,28 @@ def current_cover_position(self) -> int: """Return the current position of cover.""" # if front is open return that (other positions are impossible) # if front shade is closed get position of rear - position = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + position = (self.positions.primary / 2) + 50 if self.positions.primary == MIN_POSITION: - position = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 + position = self.positions.secondary / 2 return ceil(position) @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without - # tilt so no additional override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + # 0 - 50 represents the rear blockut shade if target_hass_position <= 50: target_hass_position = target_hass_position * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) # 51 <= target_hass_position <= 100 (51-100 represents front sheer shade) target_hass_position = (target_hass_position - 50) * 2 - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @@ -879,28 +804,19 @@ def should_poll(self) -> bool: return False @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + primary=target_hass_position, + velocity=self.positions.velocity, ) @property - def close_position(self) -> PowerviewShadeMove: + def close_position(self) -> ShadePosition: """Return the close position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MIN_POSITION, - ATTR_POSKIND1: POS_KIND_PRIMARY, - }, - {POS_KIND_SECONDARY: MAX_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + primary=MIN_POSITION, + velocity=self.positions.velocity, ) @@ -952,31 +868,22 @@ def is_closed(self) -> bool: @property def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return self.positions.secondary @callback - def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: - position_shade = hass_position_to_hd(target_hass_position, MAX_POSITION) - # note we set POS_KIND_VANE: MIN_POSITION here even with shades without tilt so no additional - # override is required for differences between type 8/9/10 - # this just stores the value in the coordinator for future reference - return PowerviewShadeMove( - { - ATTR_POSITION1: position_shade, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + def _get_shade_move(self, target_hass_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + secondary=target_hass_position, + velocity=self.positions.velocity, ) @property - def open_position(self) -> PowerviewShadeMove: + def open_position(self) -> ShadePosition: """Return the open position and required additional positions.""" - return PowerviewShadeMove( - { - ATTR_POSITION1: MAX_POSITION, - ATTR_POSKIND1: POS_KIND_SECONDARY, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_VANE: MIN_POSITION}, + return ShadePosition( + secondary=MAX_POSITION, + velocity=self.positions.velocity, ) @@ -1010,7 +917,7 @@ def __init__( | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info.model != LEGACY_DEVICE_MODEL: + if self._shade.is_supported(MOTION_STOP): self._attr_supported_features |= CoverEntityFeature.STOP_TILT self._max_tilt = self._shade.shade_limits.tilt_max @@ -1020,40 +927,32 @@ def transition_steps(self) -> int: # poskind 1 represents the second half of the shade in hass # front must be fully closed before rear can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - primary = (hd_position_to_hass(self.positions.primary, MAX_POSITION) / 2) + 50 + primary = (self.positions.primary / 2) + 50 # poskind 2 represents the shade first half of the shade in hass # rear (opaque) must be fully open before front can move # 51 - 100 is equiv to 1-100 on other shades - one motor, two shades - secondary = hd_position_to_hass(self.positions.secondary, MAX_POSITION) / 2 - vane = hd_position_to_hass(self.positions.vane, self._max_tilt) - return ceil(primary + secondary + vane) + secondary = self.positions.secondary / 2 + tilt = self.positions.tilt + return ceil(primary + secondary + tilt) @callback - def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: - """Return a PowerviewShadeMove.""" - position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) - return PowerviewShadeMove( - { - ATTR_POSITION1: position_vane, - ATTR_POSKIND1: POS_KIND_VANE, - }, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + def _get_shade_tilt(self, target_hass_tilt_position: int) -> ShadePosition: + """Return a ShadePosition.""" + return ShadePosition( + tilt=target_hass_tilt_position, + velocity=self.positions.velocity, ) @property - def open_tilt_position(self) -> PowerviewShadeMove: + def open_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, - ) + return replace(self._shade.open_position_tilt, velocity=self.positions.velocity) @property - def close_tilt_position(self) -> PowerviewShadeMove: + def close_tilt_position(self) -> ShadePosition: """Return the open tilt position and required additional positions.""" - return PowerviewShadeMove( - self._shade.open_position_tilt, - {POS_KIND_PRIMARY: MIN_POSITION, POS_KIND_SECONDARY: MAX_POSITION}, + return replace( + self._shade.close_position_tilt, velocity=self.positions.velocity ) @@ -1099,7 +998,8 @@ def create_powerview_shade_entity( shade.capability.type, (PowerViewShade,) ) _LOGGER.debug( - "%s (%s) detected as %a %s", + "%s %s (%s) detected as %a %s", + room_name, shade.name, shade.capability.type, classes, diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 78f63e1687908a..424d314c4b9e39 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,25 +1,19 @@ """The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE, BaseShade +import logging + +from aiopvapi.resources.shade import BaseShade, ShadePosition -from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_BATTERY_KIND, - BATTERY_KIND_HARDWIRED, - DOMAIN, - FIRMWARE, - FIRMWARE_BUILD, - FIRMWARE_REVISION, - FIRMWARE_SUB_REVISION, - MANUFACTURER, -) +from .const import DOMAIN, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewDeviceInfo -from .shade_data import PowerviewShadeData, PowerviewShadePositions +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @@ -39,6 +33,7 @@ def __init__( self._room_name = room_name self._attr_unique_id = unique_id self._device_info = device_info + self._configuration_url = self.coordinator.hub.url @property def data(self) -> PowerviewShadeData: @@ -48,17 +43,14 @@ def data(self) -> PowerviewShadeData: @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - firmware = self._device_info.firmware - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" return DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, identifiers={(DOMAIN, self._device_info.serial_number)}, manufacturer=MANUFACTURER, model=self._device_info.model, name=self._device_info.name, - suggested_area=self._room_name, - sw_version=sw_version, - configuration_url=f"http://{self._device_info.hub_address}/api/shades", + sw_version=self._device_info.firmware, + configuration_url=self._configuration_url, ) @@ -77,42 +69,24 @@ def __init__( super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade - self._is_hard_wired = bool( - shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED - ) + self._is_hard_wired = not shade.is_battery_powered() + self._configuration_url = shade.url @property - def positions(self) -> PowerviewShadePositions: + def positions(self) -> ShadePosition: """Return the PowerviewShadeData.""" - return self.data.get_shade_positions(self._shade.id) + return self.data.get_shade_position(self._shade.id) @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - - device_info = DeviceInfo( + return DeviceInfo( identifiers={(DOMAIN, self._shade.id)}, name=self._shade_name, suggested_area=self._room_name, manufacturer=MANUFACTURER, - model=str(self._shade.raw_data[ATTR_TYPE]), + model=self._shade.type_name, + sw_version=self._shade.firmware, via_device=(DOMAIN, self._device_info.serial_number), - configuration_url=( - f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}" - ), + configuration_url=self._configuration_url, ) - - for shade in self._shade.shade_types: - if str(shade.shade_type) == device_info[ATTR_MODEL]: - device_info[ATTR_MODEL] = shade.description - break - - if FIRMWARE not in self._shade.raw_data: - return device_info - - firmware = self._shade.raw_data[FIRMWARE] - sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" - - device_info[ATTR_SW_VERSION] = sw_version - - return device_info diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index f62879aed78b5b..276b10f5e8d823 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -18,6 +18,6 @@ }, "iot_class": "local_polling", "loggers": ["aiopvapi"], - "requirements": ["aiopvapi==2.0.4"], - "zeroconf": ["_powerview._tcp.local."] + "requirements": ["aiopvapi==3.0.2"], + "zeroconf": ["_powerview._tcp.local.", "_powerview-g3._tcp.local."] } diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py index b7ad4a7439c124..e2311eb4e4c6ba 100644 --- a/homeassistant/components/hunterdouglas_powerview/model.py +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -2,9 +2,11 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from aiopvapi.helpers.aiorequest import AioRequest +from aiopvapi.resources.room import Room +from aiopvapi.resources.scene import Scene +from aiopvapi.resources.shade import BaseShade from .coordinator import PowerviewShadeUpdateCoordinator @@ -14,9 +16,9 @@ class PowerviewEntryData: """Define class for main domain information.""" api: AioRequest - room_data: dict[str, Any] - scene_data: dict[str, Any] - shade_data: dict[str, Any] + room_data: dict[str, Room] + scene_data: dict[str, Scene] + shade_data: dict[str, BaseShade] coordinator: PowerviewShadeUpdateCoordinator device_info: PowerviewDeviceInfo @@ -28,6 +30,6 @@ class PowerviewDeviceInfo: name: str mac_address: str serial_number: str - firmware: dict[str, Any] + firmware: str | None model: str hub_address: str diff --git a/homeassistant/components/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py new file mode 100644 index 00000000000000..6b18f663c7129b --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/number.py @@ -0,0 +1,116 @@ +"""Support for hunterdouglas_powerview numbers.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Final + +from aiopvapi.helpers.constants import ATTR_NAME, MOTION_VELOCITY +from aiopvapi.resources.shade import BaseShade, ShadePosition + +from homeassistant.components.number import ( + NumberEntityDescription, + NumberMode, + RestoreNumber, +) +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 .coordinator import PowerviewShadeUpdateCoordinator +from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class PowerviewNumberDescription(NumberEntityDescription): + """Class to describe a Number entity.""" + + create_entity_fn: Callable[[BaseShade], bool] + store_value_fn: Callable[[PowerviewShadeUpdateCoordinator, int, float | None], None] + entity_category: EntityCategory = EntityCategory.CONFIG + + +def store_velocity( + coordinator: PowerviewShadeUpdateCoordinator, + shade_id: int, + value: float | None, +) -> None: + """Store the desired shade velocity in the coordinator.""" + coordinator.data.update_shade_velocity(shade_id, ShadePosition(velocity=value)) + + +NUMBERS: Final = ( + PowerviewNumberDescription( + key="velocity", + name="Velocity", + mode=NumberMode.SLIDER, + icon="mdi:speedometer", + create_entity_fn=lambda shade: shade.is_supported(MOTION_VELOCITY), + store_value_fn=store_velocity, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the hunter douglas number entities.""" + + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + + entities: list[PowerViewNumber] = [] + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") + for description in NUMBERS: + if description.create_entity_fn(shade): + entities.append( + PowerViewNumber( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) + ) + + async_add_entities(entities) + + +class PowerViewNumber(ShadeEntity, RestoreNumber): + """Representation of a number entity.""" + + entity_description: PowerviewNumberDescription + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: PowerviewDeviceInfo, + room_name: str, + shade: BaseShade, + name: str, + description: PowerviewNumberDescription, + ) -> None: + """Initialize the number entity.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self.entity_description = description + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + def set_native_value(self, value: float) -> None: + """Update the current value.""" + self._attr_native_value = value + self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + value = last_number_data.native_value if last_number_data is not None else 0 + self._attr_native_value = value + self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 4676a8d1505b8b..0ba9b13d03b79b 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -1,8 +1,10 @@ """Support for Powerview scenes from a Powerview hub.""" from __future__ import annotations +import logging from typing import Any +from aiopvapi.helpers.constants import ATTR_NAME from aiopvapi.resources.scene import Scene as PvScene from homeassistant.components.scene import Scene @@ -10,11 +12,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME +from .const import DOMAIN, STATE_ATTRIBUTE_ROOM_NAME from .coordinator import PowerviewShadeUpdateCoordinator from .entity import HDEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + +RESYNC_DELAY = 60 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,9 +30,8 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pvscenes: list[PowerViewScene] = [] - for raw_scene in pv_entry.scene_data.values(): - scene = PvScene(raw_scene, pv_entry.api) - room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + for scene in pv_entry.scene_data.values(): + room_name = getattr(pv_entry.room_data.get(scene.room_id), ATTR_NAME, "") pvscenes.append( PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) ) @@ -47,10 +52,11 @@ def __init__( ) -> None: """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) - self._scene = scene + self._scene: PvScene = scene self._attr_name = scene.name self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" - await self._scene.activate() + shades = await self._scene.activate() + _LOGGER.debug("Scene activated for shade(s) %s", shades) diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 65fe61851dfb87..bbe4614afd1a68 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -3,9 +3,11 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass +import logging from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME, FUNCTION_SET_POWER +from aiopvapi.resources.shade import BaseShade from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -13,19 +15,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - DOMAIN, - POWER_SUPPLY_TYPE_MAP, - POWER_SUPPLY_TYPE_REVERSE_MAP, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData +_LOGGER = logging.getLogger(__name__) + @dataclass(frozen=True) class PowerviewSelectDescriptionMixin: @@ -33,6 +29,8 @@ class PowerviewSelectDescriptionMixin: current_fn: Callable[[BaseShade], Any] select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] + create_entity_fn: Callable[[BaseShade], bool] + options_fn: Callable[[BaseShade], list[str]] @dataclass(frozen=True) @@ -49,13 +47,10 @@ class PowerviewSelectDescription( key="powersource", translation_key="power_source", icon="mdi:power-plug-outline", - current_fn=lambda shade: POWER_SUPPLY_TYPE_MAP.get( - shade.raw_data.get(ATTR_BATTERY_KIND), None - ), - options=list(POWER_SUPPLY_TYPE_MAP.values()), - select_fn=lambda shade, option: shade.set_power_source( - POWER_SUPPLY_TYPE_REVERSE_MAP.get(option) - ), + current_fn=lambda shade: shade.get_power_source(), + options_fn=lambda shade: shade.supported_power_sources(), + select_fn=lambda shade, option: shade.set_power_source(option), + create_entity_fn=lambda shade: shade.is_supported(FUNCTION_SET_POWER), ), ] @@ -67,26 +62,23 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] - entities = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - if SHADE_BATTERY_LEVEL not in shade.raw_data: + entities: list[PowerViewSelect] = [] + for shade in pv_entry.shade_data.values(): + if not shade.has_battery_info(): continue - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in DROPDOWNS: - entities.append( - PowerViewSelect( - pv_entry.coordinator, - pv_entry.device_info, - room_name, - shade, - name_before_refresh, - description, + if description.create_entity_fn(shade): + entities.append( + PowerViewSelect( + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + shade.name, + description, + ) ) - ) async_add_entities(entities) @@ -113,6 +105,11 @@ def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self.entity_description.current_fn(self._shade) + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self.entity_description.options_fn(self._shade) + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self.entity_description.select_fn(self._shade, option) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 8e16d53ae09c91..02b4ae7c5570ac 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -4,7 +4,8 @@ from dataclasses import dataclass from typing import Any, Final -from aiopvapi.resources.shade import BaseShade, factory as PvShade +from aiopvapi.helpers.constants import ATTR_NAME +from aiopvapi.resources.shade import BaseShade from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,21 +14,11 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_BATTERY_KIND, - ATTR_SIGNAL_STRENGTH, - ATTR_SIGNAL_STRENGTH_MAX, - BATTERY_KIND_HARDWIRED, - DOMAIN, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, - SHADE_BATTERY_LEVEL, - SHADE_BATTERY_LEVEL_MAX, -) +from .const import DOMAIN from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData @@ -38,8 +29,10 @@ class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" update_fn: Callable[[BaseShade], Any] + device_class_fn: Callable[[BaseShade], SensorDeviceClass | None] native_value_fn: Callable[[BaseShade], int] - create_sensor_fn: Callable[[BaseShade], bool] + native_unit_fn: Callable[[BaseShade], str | None] + create_entity_fn: Callable[[BaseShade], bool] @dataclass(frozen=True) @@ -52,29 +45,33 @@ class PowerviewSensorDescription( state_class = SensorStateClass.MEASUREMENT +def get_signal_device_class(shade: BaseShade) -> SensorDeviceClass | None: + """Get the signal value based on version of API.""" + return SensorDeviceClass.SIGNAL_STRENGTH if shade.api_version >= 3 else None + + +def get_signal_native_unit(shade: BaseShade) -> str: + """Get the unit of measurement for signal based on version of API.""" + return SIGNAL_STRENGTH_DECIBELS if shade.api_version >= 3 else PERCENTAGE + + SENSORS: Final = [ PowerviewSensorDescription( key="charge", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[SHADE_BATTERY_LEVEL] / SHADE_BATTERY_LEVEL_MAX * 100 - ), - create_sensor_fn=lambda shade: bool( - shade.raw_data.get(ATTR_BATTERY_KIND) != BATTERY_KIND_HARDWIRED - and SHADE_BATTERY_LEVEL in shade.raw_data - ), + device_class_fn=lambda shade: SensorDeviceClass.BATTERY, + native_unit_fn=lambda shade: PERCENTAGE, + native_value_fn=lambda shade: shade.get_battery_strength(), + create_entity_fn=lambda shade: shade.is_battery_powered(), update_fn=lambda shade: shade.refresh_battery(), ), PowerviewSensorDescription( key="signal", translation_key="signal_strength", icon="mdi:signal", - native_unit_of_measurement=PERCENTAGE, - native_value_fn=lambda shade: round( - shade.raw_data[ATTR_SIGNAL_STRENGTH] / ATTR_SIGNAL_STRENGTH_MAX * 100 - ), - create_sensor_fn=lambda shade: bool(ATTR_SIGNAL_STRENGTH in shade.raw_data), + device_class_fn=get_signal_device_class, + native_unit_fn=get_signal_native_unit, + native_value_fn=lambda shade: shade.get_signal_strength(), + create_entity_fn=lambda shade: shade.has_signal_strength(), update_fn=lambda shade: shade.refresh(), entity_registry_enabled_default=False, ), @@ -89,21 +86,17 @@ async def async_setup_entry( pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[PowerViewSensor] = [] - for raw_shade in pv_entry.shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_entry.api) - name_before_refresh = shade.name - room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - + for shade in pv_entry.shade_data.values(): + room_name = getattr(pv_entry.room_data.get(shade.room_id), ATTR_NAME, "") for description in SENSORS: - if description.create_sensor_fn(shade): + if description.create_entity_fn(shade): entities.append( PowerViewSensor( pv_entry.coordinator, pv_entry.device_info, room_name, shade, - name_before_refresh, + shade.name, description, ) ) @@ -125,17 +118,27 @@ def __init__( name: str, description: PowerviewSensorDescription, ) -> None: - """Initialize the select entity.""" + """Initialize the sensor entity.""" super().__init__(coordinator, device_info, room_name, shade, name) self.entity_description = description + self.entity_description: PowerviewSensorDescription = description self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" - self._attr_native_unit_of_measurement = description.native_unit_of_measurement @property def native_value(self) -> int: - """Get the current value in percentage.""" + """Get the current value of the sensor.""" return self.entity_description.native_value_fn(self._shade) + @property + def native_unit_of_measurement(self) -> str | None: + """Return native unit of measurement of sensor.""" + return self.entity_description.native_unit_fn(self._shade) + + @property + def device_class(self) -> SensorDeviceClass | None: + """Return the class of this entity.""" + return self.entity_description.device_class_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py index fab14b540b7a8a..86f232c3b667d4 100644 --- a/homeassistant/components/hunterdouglas_powerview/shade_data.py +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -1,59 +1,25 @@ """Shade data for the Hunter Douglas PowerView integration.""" from __future__ import annotations -from collections.abc import Iterable -from dataclasses import dataclass import logging from typing import Any -from aiopvapi.helpers.constants import ( - ATTR_ID, - ATTR_POSITION1, - ATTR_POSITION2, - ATTR_POSITION_DATA, - ATTR_POSKIND1, - ATTR_POSKIND2, - ATTR_SHADE, -) -from aiopvapi.resources.shade import MIN_POSITION - -from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE -from .util import async_map_data_by_id +from aiopvapi.resources.model import PowerviewData +from aiopvapi.resources.shade import BaseShade, ShadePosition -POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) +from .util import async_map_data_by_id _LOGGER = logging.getLogger(__name__) -@dataclass -class PowerviewShadeMove: - """Request to move a powerview shade.""" - - # The positions to request on the hub - request: dict[str, int] - - # The positions that will also change - # as a result of the request that the - # hub will not send back - new_positions: dict[int, int] - - -@dataclass -class PowerviewShadePositions: - """Positions for a powerview shade.""" - - primary: int = MIN_POSITION - secondary: int = MIN_POSITION - vane: int = MIN_POSITION - - class PowerviewShadeData: """Coordinate shade data between multiple api calls.""" def __init__(self) -> None: """Init the shade data.""" self._group_data_by_id: dict[int, dict[str | int, Any]] = {} - self.positions: dict[int, PowerviewShadePositions] = {} + self._shade_data_by_id: dict[int, BaseShade] = {} + self.positions: dict[int, ShadePosition] = {} def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: """Get data for the shade.""" @@ -63,17 +29,21 @@ def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]: """Get data for all shades.""" return self._group_data_by_id - def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + def get_shade(self, shade_id: int) -> BaseShade: + """Get specific shade from the coordinator.""" + return self._shade_data_by_id[shade_id] + + def get_shade_position(self, shade_id: int) -> ShadePosition: """Get positions for a shade.""" if shade_id not in self.positions: - self.positions[shade_id] = PowerviewShadePositions() + self.positions[shade_id] = ShadePosition() return self.positions[shade_id] def update_from_group_data(self, shade_id: int) -> None: """Process an update from the group data.""" - self.update_shade_positions(self._group_data_by_id[shade_id]) + self.update_shade_positions(self._shade_data_by_id[shade_id]) - def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + def store_group_data(self, shade_data: PowerviewData) -> None: """Store data from the all shades endpoint. This does not update the shades or positions @@ -81,37 +51,34 @@ def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: with a shade_id will update a specific shade from the group data. """ - self._group_data_by_id = async_map_data_by_id(shade_data) - - def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: - """Update a single shade position.""" - positions = self.get_shade_positions(shade_id) - if kind == POS_KIND_PRIMARY: - positions.primary = position - elif kind == POS_KIND_SECONDARY: - positions.secondary = position - elif kind == POS_KIND_VANE: - positions.vane = position - - def update_from_position_data( - self, shade_id: int, position_data: dict[str, Any] - ) -> None: - """Update the shade positions from the position data.""" - for position_key, kind_key in POSITIONS: - if position_key in position_data: - self.update_shade_position( - shade_id, position_data[position_key], position_data[kind_key] - ) - - def update_shade_positions(self, data: dict[int | str, Any]) -> None: + self._shade_data_by_id = shade_data.processed + self._group_data_by_id = async_map_data_by_id(shade_data.raw) + + def update_shade_position(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades position.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() + + # ShadePosition will return None if the value is not set + if shade_data.primary is not None: + self.positions[shade_id].primary = shade_data.primary + if shade_data.secondary is not None: + self.positions[shade_id].secondary = shade_data.secondary + if shade_data.tilt is not None: + self.positions[shade_id].tilt = shade_data.tilt + + def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None: + """Update a single shades velocity.""" + if shade_id not in self.positions: + self.positions[shade_id] = ShadePosition() + + # the hub will always return a velocity of 0 on initial connect, + # separate definition to store consistent value in HA + # this value is purely driven from HA + if shade_data.velocity is not None: + self.positions[shade_id].velocity = shade_data.velocity + + def update_shade_positions(self, data: BaseShade) -> None: """Update a shades from data dict.""" - _LOGGER.debug("Raw data update: %s", data) - shade_id = data[ATTR_ID] - position_data = data[ATTR_POSITION_DATA] - self.update_from_position_data(shade_id, position_data) - - def update_from_response(self, response: dict[str, Any]) -> None: - """Update from the response to a command.""" - if response and ATTR_SHADE in response: - shade_data: dict[int | str, Any] = response[ATTR_SHADE] - self.update_shade_positions(shade_data) + _LOGGER.debug("Raw data update: %s", data.raw_data) + self.update_shade_position(data.id, data.current_position) diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index 7c17788be8353d..a107e2c5be4765 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -4,7 +4,11 @@ "user": { "title": "Connect to the PowerView Hub", "data": { - "host": "[%key:common::config_flow::data::ip%]" + "host": "[%key:common::config_flow::data::ip%]", + "api_version": "Hub Generation" + }, + "data_description": { + "api_version": "API version is detectable, but you can override and force a specific version" } }, "link": { @@ -15,6 +19,7 @@ "flow_title": "{name} ({host})", "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unsupported_device": "Only the primary powerview hub can be added", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/hurrican_shutters_wholesale/__init__.py b/homeassistant/components/hurrican_shutters_wholesale/__init__.py new file mode 100644 index 00000000000000..a54f98a78c194f --- /dev/null +++ b/homeassistant/components/hurrican_shutters_wholesale/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Hurrican shutters wholesale.""" diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py new file mode 100644 index 00000000000000..20218229385f77 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -0,0 +1,59 @@ +"""The Husqvarna Automower integration.""" + +import logging + +from aioautomower.session import AutomowerSession +from aiohttp import ClientError + +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, config_entry_oauth2_flow + +from . import api +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.LAWN_MOWER, Platform.SENSOR, Platform.SWITCH] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + api_api = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), + session, + ) + automower_api = AutomowerSession(api_api) + try: + await api_api.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) + await coordinator.async_config_entry_first_refresh() + entry.async_create_background_task( + hass, + coordinator.client_listen(hass, entry, automower_api), + "websocket_task", + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle unload of an entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/husqvarna_automower/api.py b/homeassistant/components/husqvarna_automower/api.py new file mode 100644 index 00000000000000..e5dc00ad7cbfd8 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/api.py @@ -0,0 +1,29 @@ +"""API for Husqvarna Automower bound to Home Assistant OAuth.""" + +import logging + +from aioautomower.auth import AbstractAuth +from aioautomower.const import API_BASE_URL +from aiohttp import ClientSession + +from homeassistant.helpers import config_entry_oauth2_flow + +_LOGGER = logging.getLogger(__name__) + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Husqvarna Automower authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Husqvarna Automower auth.""" + super().__init__(websession, API_BASE_URL) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/husqvarna_automower/application_credentials.py b/homeassistant/components/husqvarna_automower/application_credentials.py new file mode 100644 index 00000000000000..f201130ab224ff --- /dev/null +++ b/homeassistant/components/husqvarna_automower/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials platform for Husqvarna Automower.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py new file mode 100644 index 00000000000000..cafe942a8944d4 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/config_flow.py @@ -0,0 +1,43 @@ +"""Config flow to add the integration via the UI.""" +import logging +from typing import Any + +from aioautomower.utils import async_structure_token + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) +CONF_USER_ID = "user_id" + + +class HusqvarnaConfigFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, + domain=DOMAIN, +): + """Handle a config flow.""" + + VERSION = 1 + DOMAIN = DOMAIN + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + token = data[CONF_TOKEN] + user_id = token[CONF_USER_ID] + structured_token = await async_structure_token(token[CONF_ACCESS_TOKEN]) + first_name = structured_token.user.first_name + last_name = structured_token.user.last_name + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{NAME} of {first_name} {last_name}", + data=data, + ) + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py new file mode 100644 index 00000000000000..ab30bae45f2f47 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/const.py @@ -0,0 +1,7 @@ +"""The constants for the Husqvarna Automower integration.""" + +DOMAIN = "husqvarna_automower" +NAME = "Husqvarna Automower" +HUSQVARNA_URL = "https://developer.husqvarnagroup.cloud/login" +OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" +OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py new file mode 100644 index 00000000000000..2840823415aae3 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -0,0 +1,78 @@ +"""Data UpdateCoordinator for the Husqvarna Automower integration.""" +import asyncio +from datetime import timedelta +import logging + +from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError +from aioautomower.model import MowerAttributes +from aioautomower.session import AutomowerSession + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +MAX_WS_RECONNECT_TIME = 600 + + +class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): + """Class to manage fetching Husqvarna data.""" + + def __init__( + self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry + ) -> None: + """Initialize data updater.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self.api = api + + self.ws_connected: bool = False + + async def _async_update_data(self) -> dict[str, MowerAttributes]: + """Subscribe for websocket and poll data from the API.""" + if not self.ws_connected: + await self.api.connect() + self.api.register_data_callback(self.callback) + self.ws_connected = True + try: + return await self.api.get_status() + except ApiException as err: + raise UpdateFailed(err) from err + + @callback + def callback(self, ws_data: dict[str, MowerAttributes]) -> None: + """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.async_set_updated_data(ws_data) + + async def client_listen( + self, + hass: HomeAssistant, + entry: ConfigEntry, + automower_client: AutomowerSession, + reconnect_time: int = 2, + ) -> None: + """Listen with the client.""" + try: + await automower_client.auth.websocket_connect() + reconnect_time = 2 + await automower_client.start_listening() + except HusqvarnaWSServerHandshakeError as err: + _LOGGER.debug( + "Failed to connect to websocket. Trying to reconnect: %s", err + ) + + if not hass.is_stopping: + await asyncio.sleep(reconnect_time) + reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME) + await self.client_listen( + hass=hass, + entry=entry, + automower_client=automower_client, + reconnect_time=reconnect_time, + ) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py new file mode 100644 index 00000000000000..2edce942f0c69b --- /dev/null +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -0,0 +1,49 @@ +"""Platform for Husqvarna Automower base entity.""" + +import logging + +from aioautomower.model import MowerAttributes + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AutomowerDataUpdateCoordinator +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): + """Defining the Automower base Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize AutomowerEntity.""" + super().__init__(coordinator) + self.mower_id = mower_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mower_id)}, + name=self.mower_attributes.system.name, + manufacturer="Husqvarna", + model=self.mower_attributes.system.model, + suggested_area="Garden", + ) + + @property + def mower_attributes(self) -> MowerAttributes: + """Get the mower attributes of the current mower.""" + return self.coordinator.data[self.mower_id] + + +class AutomowerControlEntity(AutomowerBaseEntity): + """AutomowerControlEntity, for dynamic availability.""" + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_attributes.metadata.connected diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py new file mode 100644 index 00000000000000..abf27af02f0489 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -0,0 +1,107 @@ +"""Husqvarna Automower lawn mower entity.""" +import logging + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerActivities, MowerStates + +from homeassistant.components.lawn_mower import ( + LawnMowerActivity, + LawnMowerEntity, + LawnMowerEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerControlEntity + +SUPPORT_STATE_SERVICES = ( + LawnMowerEntityFeature.DOCK + | LawnMowerEntityFeature.PAUSE + | LawnMowerEntityFeature.START_MOWING +) + +DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) +MOWING_ACTIVITIES = ( + MowerActivities.MOWING, + MowerActivities.LEAVING, + MowerActivities.GOING_HOME, +) +PAUSED_STATES = [ + MowerStates.PAUSED, + MowerStates.WAIT_UPDATING, + MowerStates.WAIT_POWER_UP, +] + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up lawn mower platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerLawnMowerEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerLawnMowerEntity(AutomowerControlEntity, LawnMowerEntity): + """Defining each mower Entity.""" + + _attr_name = None + _attr_supported_features = SUPPORT_STATE_SERVICES + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up HusqvarnaAutomowerEntity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = mower_id + + @property + def activity(self) -> LawnMowerActivity: + """Return the state of the mower.""" + mower_attributes = self.mower_attributes + if mower_attributes.mower.state in PAUSED_STATES: + return LawnMowerActivity.PAUSED + if mower_attributes.mower.activity in MOWING_ACTIVITIES: + return LawnMowerActivity.MOWING + if (mower_attributes.mower.state == "RESTRICTED") or ( + mower_attributes.mower.activity in DOCKED_ACTIVITIES + ): + return LawnMowerActivity.DOCKED + return LawnMowerActivity.ERROR + + async def async_start_mowing(self) -> None: + """Resume schedule.""" + try: + await self.coordinator.api.resume_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_pause(self) -> None: + """Pauses the mower.""" + try: + await self.coordinator.api.pause_mowing(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_dock(self) -> None: + """Parks the mower until next schedule.""" + try: + await self.coordinator.api.park_until_next_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json new file mode 100644 index 00000000000000..dc40116f31efcb --- /dev/null +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "husqvarna_automower", + "name": "Husqvarna Automower", + "codeowners": ["@Thomas55555"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", + "iot_class": "cloud_push", + "requirements": ["aioautomower==2024.2.10"] +} diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py new file mode 100644 index 00000000000000..970c444737c340 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -0,0 +1,171 @@ +"""Creates a the sensor entities for the mower.""" +from collections.abc import Callable +from dataclasses import dataclass +import datetime +import logging + +from aioautomower.model import MowerAttributes, MowerModes + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class AutomowerSensorEntityDescription(SensorEntityDescription): + """Describes Automower sensor entity.""" + + exists_fn: Callable[[MowerAttributes], bool] = lambda _: True + value_fn: Callable[[MowerAttributes], str] + + +SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( + AutomowerSensorEntityDescription( + key="battery_percent", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.battery.battery_percent, + ), + AutomowerSensorEntityDescription( + key="mode", + translation_key="mode", + device_class=SensorDeviceClass.ENUM, + options=[option.lower() for option in list(MowerModes)], + value_fn=( + lambda data: data.mower.mode.lower() + if data.mower.mode != MowerModes.UNKNOWN + else None + ), + ), + AutomowerSensorEntityDescription( + key="cutting_blade_usage_time", + translation_key="cutting_blade_usage_time", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None, + value_fn=lambda data: data.statistics.cutting_blade_usage_time, + ), + AutomowerSensorEntityDescription( + key="total_charging_time", + translation_key="total_charging_time", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: data.statistics.total_charging_time, + ), + AutomowerSensorEntityDescription( + key="total_cutting_time", + translation_key="total_cutting_time", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: data.statistics.total_cutting_time, + ), + AutomowerSensorEntityDescription( + key="total_running_time", + translation_key="total_running_time", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: data.statistics.total_running_time, + ), + AutomowerSensorEntityDescription( + key="total_searching_time", + translation_key="total_searching_time", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + value_fn=lambda data: data.statistics.total_searching_time, + ), + AutomowerSensorEntityDescription( + key="number_of_charging_cycles", + translation_key="number_of_charging_cycles", + icon="mdi:battery-sync-outline", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.statistics.number_of_charging_cycles, + ), + AutomowerSensorEntityDescription( + key="number_of_collisions", + translation_key="number_of_collisions", + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data: data.statistics.number_of_collisions, + ), + AutomowerSensorEntityDescription( + key="total_drive_distance", + translation_key="total_drive_distance", + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_unit_of_measurement=UnitOfLength.KILOMETERS, + value_fn=lambda data: data.statistics.total_drive_distance, + ), + AutomowerSensorEntityDescription( + key="next_start_timestamp", + translation_key="next_start_timestamp", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.planner.next_start_dateteime, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensor platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerSensorEntity(mower_id, coordinator, description) + for mower_id in coordinator.data + for description in SENSOR_TYPES + if description.exists_fn(coordinator.data[mower_id]) + ) + + +class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): + """Defining the Automower Sensors with AutomowerSensorEntityDescription.""" + + entity_description: AutomowerSensorEntityDescription + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + description: AutomowerSensorEntityDescription, + ) -> None: + """Set up AutomowerSensors.""" + super().__init__(mower_id, coordinator) + self.entity_description = description + self._attr_unique_id = f"{mower_id}_{description.key}" + + @property + def native_value(self) -> str | int | datetime.datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.mower_attributes) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json new file mode 100644 index 00000000000000..d6017de2bd77a4 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -0,0 +1,71 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "switch": { + "enable_schedule": { + "name": "Enable schedule" + } + }, + "sensor": { + "number_of_charging_cycles": { + "name": "Number of charging cycles" + }, + "number_of_collisions": { + "name": "Number of collisions" + }, + "cutting_blade_usage_time": { + "name": "Cutting blade usage time" + }, + "total_charging_time": { + "name": "Total charging time" + }, + "total_cutting_time": { + "name": "Total cutting time" + }, + "total_running_time": { + "name": "Total running time" + }, + "total_searching_time": { + "name": "Total searching time" + }, + "total_drive_distance": { + "name": "Total drive distance" + }, + "next_start_timestamp": { + "name": "Next start" + }, + "mode": { + "name": "Mode", + "state": { + "main_area": "Main area", + "secondary_area": "Secondary area", + "home": "Home", + "demo": "Demo" + } + } + } + } +} diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py new file mode 100644 index 00000000000000..9ba760a90e9fff --- /dev/null +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -0,0 +1,93 @@ +"""Creates a switch entity for the mower.""" +import logging +from typing import Any + +from aioautomower.exceptions import ApiException +from aioautomower.model import MowerActivities, MowerStates, RestrictedReasons + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerControlEntity + +_LOGGER = logging.getLogger(__name__) + +ERROR_ACTIVITIES = ( + MowerActivities.STOPPED_IN_GARDEN, + MowerActivities.UNKNOWN, + MowerActivities.NOT_APPLICABLE, +) +ERROR_STATES = [ + MowerStates.FATAL_ERROR, + MowerStates.ERROR, + MowerStates.ERROR_AT_POWER_UP, + MowerStates.NOT_APPLICABLE, + MowerStates.UNKNOWN, + MowerStates.STOPPED, + MowerStates.OFF, +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up switch platform.""" + coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + AutomowerSwitchEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerSwitchEntity(AutomowerControlEntity, SwitchEntity): + """Defining the Automower switch.""" + + _attr_translation_key = "enable_schedule" + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up Automower switch.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{self.mower_id}_{self._attr_translation_key}" + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + attributes = self.mower_attributes + return not ( + attributes.mower.state == MowerStates.RESTRICTED + and attributes.planner.restricted_reason == RestrictedReasons.NOT_APPLICABLE + ) + + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and ( + self.mower_attributes.mower.state not in ERROR_STATES + or self.mower_attributes.mower.activity not in ERROR_ACTIVITIES + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + try: + await self.coordinator.api.park_until_further_notice(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + try: + await self.coordinator.api.resume_schedule(self.mower_id) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception diff --git a/homeassistant/components/hvv_departures/icons.json b/homeassistant/components/hvv_departures/icons.json new file mode 100644 index 00000000000000..5c056e5765393b --- /dev/null +++ b/homeassistant/components/hvv_departures/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "departures": { + "default": "mdi:bus" + } + } + } +} diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index b30a9b375b0600..2267522e21bebd 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -21,7 +21,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) MAX_LIST = 20 MAX_TIME_OFFSET = 360 -ICON = "mdi:bus" ATTR_DEPARTURE = "departure" ATTR_LINE = "line" @@ -42,7 +41,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor platform.""" hub = hass.data[DOMAIN][config_entry.entry_id] @@ -50,7 +49,7 @@ async def async_setup_entry( session = aiohttp_client.async_get_clientsession(hass) sensor = HVVDepartureSensor(hass, config_entry, session, hub) - async_add_devices([sensor], True) + async_add_entities([sensor], True) class HVVDepartureSensor(SensorEntity): @@ -58,7 +57,6 @@ class HVVDepartureSensor(SensorEntity): _attr_attribution = ATTRIBUTION _attr_device_class = SensorDeviceClass.TIMESTAMP - _attr_icon = ICON _attr_translation_key = "departures" _attr_has_entity_name = True _attr_available = False diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 0bfe1dff001e6a..5181de7d2a454a 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.1.0"] + "requirements": ["pydrawise==2024.3.0"] } diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index 1b8210259536ee..ff54c02a2d4ae2 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(10): mac = await hass.async_add_executor_job(ialarm.get_mac) - except (asyncio.TimeoutError, ConnectionError) as ex: + except (TimeoutError, ConnectionError) as ex: raise ConfigEntryNotReady from ex coordinator = IAlarmDataUpdateCoordinator(hass, ialarm, mac) diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index df3a873b6c19cf..3537737f122dfb 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -1,7 +1,6 @@ """Support for iammeter via local API.""" from __future__ import annotations -import asyncio from asyncio import timeout from collections.abc import Callable from dataclasses import dataclass @@ -117,7 +116,7 @@ async def async_setup_platform( api = await hass.async_add_executor_job( IamMeter, config_host, config_port, config_name ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Device is not ready") raise PlatformNotReady from err @@ -125,7 +124,7 @@ async def async_update_data(): try: async with timeout(PLATFORM_TIMEOUT): return await hass.async_add_executor_job(api.client.get_data) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise UpdateFailed from err coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 062548666c4895..49eaa2b24a5a9d 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -1,7 +1,6 @@ """Component to embed Aqualink devices.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine from datetime import datetime from functools import wraps @@ -79,7 +78,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER.error("Failed to login: %s", login_exception) await aqualink.close() return False - except (asyncio.TimeoutError, httpx.HTTPError) as aio_exception: + except (TimeoutError, httpx.HTTPError) as aio_exception: await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting login: {aio_exception}" diff --git a/homeassistant/components/ibeacon/manifest.json b/homeassistant/components/ibeacon/manifest.json index 6f00f63b0909af..8dbc99c8ada712 100644 --- a/homeassistant/components/ibeacon/manifest.json +++ b/homeassistant/components/ibeacon/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/ibeacon", "iot_class": "local_push", "loggers": ["bleak"], - "requirements": ["ibeacon-ble==1.0.1"] + "requirements": ["ibeacon-ble==1.2.0"] } diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index 80e07fe1065a1e..84e97534d7cf2f 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["idasen-ha==2.5"] + "requirements": ["idasen-ha==2.5.1"] } diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index f4e04ea762b8cc..0fb3523a4611e3 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -91,10 +91,14 @@ def __init__( async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() - self._handle_coordinator_update() + self._update_native_value() @callback def _handle_coordinator_update(self, *args: Any) -> None: """Handle data update.""" - self._attr_native_value = self.entity_description.value_fn(self.coordinator) + self._update_native_value() super()._handle_coordinator_update() + + def _update_native_value(self) -> None: + """Update the native value attribute.""" + self._attr_native_value = self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json index 6eeea6b4a02bcc..c76013f6821200 100644 --- a/homeassistant/components/ign_sismologia/manifest.json +++ b/homeassistant/components/ign_sismologia/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_ign_sismologia_client"], - "requirements": ["georss-ign-sismologia-client==0.6"] + "requirements": ["georss-ign-sismologia-client==0.8"] } diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 0c077f8698eb53..30c84da40f81eb 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -39,7 +39,7 @@ def __init__( self.ihc_name = product["name"] self.ihc_note = product["note"] self.ihc_position = product["position"] - self.suggested_area = product["group"] if "group" in product else None + self.suggested_area = product.get("group") if "id" in product: product_id = product["id"] self.device_id = f"{controller_id}_{product_id }" diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 4c5a9df881010f..164b7048da8271 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -15,7 +15,7 @@ from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONTENT_TYPE_MULTIPART, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -24,9 +24,13 @@ ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, + async_track_time_interval, +) from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, EventType, UndefinedType from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 @@ -49,6 +53,10 @@ GET_IMAGE_TIMEOUT: Final = 10 +FRAME_BOUNDARY = "frame-boundary" +FRAME_SEPARATOR = bytes(f"\r\n--{FRAME_BOUNDARY}\r\n", "utf-8") +LAST_FRAME_MARKER = bytes(f"\r\n--{FRAME_BOUNDARY}--\r\n", "utf-8") + class ImageEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes image entities.""" @@ -75,7 +83,7 @@ def valid_image_content_type(content_type: str | None) -> str: async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: """Fetch image from an image entity.""" - with suppress(asyncio.CancelledError, asyncio.TimeoutError, ImageContentTypeError): + with suppress(asyncio.CancelledError, TimeoutError, ImageContentTypeError): async with asyncio.timeout(timeout): if image_bytes := await image_entity.async_image(): content_type = valid_image_content_type(image_entity.content_type) @@ -92,6 +100,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.http.register_view(ImageView(component)) + hass.http.register_view(ImageStreamView(component)) await component.async_setup(config) @@ -295,3 +304,71 @@ async def handle( raise web.HTTPInternalServerError() from ex return web.Response(body=image.content, content_type=image.content_type) + + +async def async_get_still_stream( + request: web.Request, + image_entity: ImageEntity, +) -> web.StreamResponse: + """Generate an HTTP multipart stream from the Image.""" + response = web.StreamResponse() + response.content_type = CONTENT_TYPE_MULTIPART.format(FRAME_BOUNDARY) + await response.prepare(request) + + async def _write_frame() -> bool: + img_bytes = await image_entity.async_image() + if img_bytes is None: + await response.write(LAST_FRAME_MARKER) + return False + frame = bytearray(FRAME_SEPARATOR) + header = bytes( + f"Content-Type: {image_entity.content_type}\r\n" + f"Content-Length: {len(img_bytes)}\r\n\r\n", + "utf-8", + ) + frame.extend(header) + frame.extend(img_bytes) + # Chrome shows the n-1 frame so send the frame twice + # https://issues.chromium.org/issues/41199053 + # https://issues.chromium.org/issues/40791855 + # While this results in additional bandwidth usage, + # given the low frequency of image updates, it is acceptable. + frame.extend(frame) + await response.write(frame) + # Drain to ensure that the latest frame is available to the client + await response.drain() + return True + + event = asyncio.Event() + + async def image_state_update(_event: EventType[EventStateChangedData]) -> None: + """Write image to stream.""" + event.set() + + hass: HomeAssistant = request.app["hass"] + remove = async_track_state_change_event( + hass, + image_entity.entity_id, + image_state_update, + ) + try: + while True: + if not await _write_frame(): + return response + await event.wait() + event.clear() + finally: + remove() + + +class ImageStreamView(ImageView): + """Image View to serve an multipart stream.""" + + url = "/api/image_proxy_stream/{entity_id}" + name = "api:image:stream" + + async def handle( + self, request: web.Request, image_entity: ImageEntity + ) -> web.StreamResponse: + """Serve image stream.""" + return await async_get_still_stream(request, image_entity) diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py new file mode 100644 index 00000000000000..39f00b587c022c --- /dev/null +++ b/homeassistant/components/image/media_source.py @@ -0,0 +1,84 @@ +"""Expose iamges as media sources.""" +from __future__ import annotations + +from typing import cast + +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_component import EntityComponent + +from . import ImageEntity +from .const import DOMAIN + + +async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource: + """Set up image media source.""" + return ImageMediaSource(hass) + + +class ImageMediaSource(MediaSource): + """Provide images as media sources.""" + + name: str = "Image" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ImageMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] + image = component.get_entity(item.identifier) + + if not image: + raise Unresolvable(f"Could not resolve media item: {item.identifier}") + + return PlayMedia( + f"/api/image_proxy_stream/{image.entity_id}", image.content_type + ) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier: + raise BrowseError("Unknown item") + + component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=image.entity_id, + media_class=MediaClass.VIDEO, + media_content_type=image.content_type, + title=cast(State, self.hass.states.get(image.entity_id)).attributes.get( + ATTR_FRIENDLY_NAME, image.name + ), + thumbnail=f"/api/image_proxy/{image.entity_id}", + can_play=True, + can_expand=False, + ) + for image in component.entities + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Image", + can_play=False, + can_expand=True, + children_media_class=MediaClass.IMAGE, + children=children, + ) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index bb356c09367282..178d40d1139fe6 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -262,7 +262,7 @@ def async_process_faces(self, faces: list[FaceInformation], total: int) -> None: continue face.update({ATTR_ENTITY_ID: self.entity_id}) - self.hass.bus.async_fire(EVENT_DETECT_FACE, face) # type: ignore[arg-type] + self.hass.bus.async_fire(EVENT_DETECT_FACE, face) # Update entity store self.faces = faces diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index fea2583a27a3cb..924408c30b97d5 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -1,8 +1,6 @@ """The imap integration.""" from __future__ import annotations -import asyncio - from aioimaplib import IMAP4_SSL, AioImapException from homeassistant.config_entries import ConfigEntry @@ -33,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed from err except InvalidFolder as err: raise ConfigEntryError("Selected mailbox folder is invalid.") from err - except (asyncio.TimeoutError, AioImapException) as err: + except (TimeoutError, AioImapException) as err: raise ConfigEntryNotReady from err coordinator_class: type[ diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index dea7a0e2e71020..15b52ce6333f03 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -1,7 +1,6 @@ """Config flow for imap integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import ssl from typing import Any @@ -108,7 +107,7 @@ async def validate_input( # See https://github.com/bamthomas/aioimaplib/issues/91 # This handler is added to be able to supply a better error message errors["base"] = "ssl_error" - except (asyncio.TimeoutError, AioImapException, ConnectionRefusedError): + except (TimeoutError, AioImapException, ConnectionRefusedError): errors["base"] = "cannot_connect" else: if result != "OK": diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 49938eaaa0a02f..f0c9099863a38f 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -30,6 +30,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from homeassistant.util.ssl import ( SSLCipherList, client_context, @@ -57,6 +58,8 @@ MAX_ERRORS = 3 MAX_EVENT_DATA_BYTES = 32168 +DIAGNOSTICS_ATTRIBUTES = ["date", "initial"] + async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" @@ -101,6 +104,23 @@ def __init__(self, raw_message: bytes) -> None: """Initialize IMAP message.""" self.email_message = email.message_from_bytes(raw_message) + @staticmethod + def _decode_payload(part: Message) -> str: + """Try to decode text payloads. + + Common text encodings are quoted-printable or base64. + Falls back to the raw content part if decoding fails. + """ + try: + decoded_payload: Any = part.get_payload(decode=True) + if TYPE_CHECKING: + assert isinstance(decoded_payload, bytes) + content_charset = part.get_content_charset() or "utf-8" + return decoded_payload.decode(content_charset) + except ValueError: + # return undecoded payload + return str(part.get_payload()) + @property def headers(self) -> dict[str, tuple[str,]]: """Get the email headers.""" @@ -158,30 +178,14 @@ def text(self) -> str: message_html: str | None = None message_untyped_text: str | None = None - def _decode_payload(part: Message) -> str: - """Try to decode text payloads. - - Common text encodings are quoted-printable or base64. - Falls back to the raw content part if decoding fails. - """ - try: - decoded_payload: Any = part.get_payload(decode=True) - if TYPE_CHECKING: - assert isinstance(decoded_payload, bytes) - content_charset = part.get_content_charset() or "utf-8" - return decoded_payload.decode(content_charset) - except ValueError: - # return undecoded payload - return str(part.get_payload()) - part: Message for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: if message_text is None: - message_text = _decode_payload(part) + message_text = self._decode_payload(part) elif part.get_content_type() == "text/html": if message_html is None: - message_html = _decode_payload(part) + message_html = self._decode_payload(part) elif ( part.get_content_type().startswith("text") and message_untyped_text is None @@ -219,6 +223,7 @@ def __init__( self._last_message_uid: str | None = None self._last_message_id: str | None = None self.custom_event_template = None + self._diagnostics_data: dict[str, Any] = {} _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) if _custom_event_template is not None: self.custom_event_template = Template(_custom_event_template, hass=hass) @@ -286,6 +291,7 @@ async def _async_process_event(self, last_message_uid: str) -> None: CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE ) ] + self._update_diagnostics(data) if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: _LOGGER.warning( "Custom imap_content event skipped, size (%s) exceeds " @@ -346,7 +352,7 @@ async def _cleanup(self, log_error: bool = False) -> None: await self.imap_client.stop_wait_server_push() await self.imap_client.close() await self.imap_client.logout() - except (AioImapException, asyncio.TimeoutError): + except (AioImapException, TimeoutError): if log_error: _LOGGER.debug("Error while cleaning up imap connection") finally: @@ -356,6 +362,23 @@ async def shutdown(self, *_: Any) -> None: """Close resources.""" await self._cleanup(log_error=True) + def _update_diagnostics(self, data: dict[str, Any]) -> None: + """Update the diagnostics.""" + self._diagnostics_data.update( + {key: value for key, value in data.items() if key in DIAGNOSTICS_ATTRIBUTES} + ) + custom: Any | None = data.get("custom") + self._diagnostics_data["custom_template_data_type"] = str(type(custom)) + self._diagnostics_data["custom_template_result_length"] = ( + None if custom is None else len(f"{custom}") + ) + self._diagnostics_data["event_time"] = dt_util.now().isoformat() + + @property + def diagnostics_data(self) -> dict[str, Any]: + """Return diagnostics info.""" + return self._diagnostics_data + class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" @@ -378,7 +401,7 @@ async def _async_update_data(self) -> int | None: except ( AioImapException, UpdateFailed, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -450,7 +473,7 @@ async def _async_wait_push_loop(self) -> None: except ( UpdateFailed, AioImapException, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -466,8 +489,7 @@ async def _async_wait_push_loop(self) -> None: async with asyncio.timeout(10): await idle - # From python 3.11 asyncio.TimeoutError is an alias of TimeoutError - except (AioImapException, asyncio.TimeoutError): + except (AioImapException, TimeoutError): _LOGGER.debug( "Lost %s (will attempt to reconnect after %s s)", self.config_entry.data[CONF_SERVER], diff --git a/homeassistant/components/imap/diagnostics.py b/homeassistant/components/imap/diagnostics.py new file mode 100644 index 00000000000000..c7d5151ba49c48 --- /dev/null +++ b/homeassistant/components/imap/diagnostics.py @@ -0,0 +1,38 @@ +"""Diagnostics support for IMAP.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import ImapDataUpdateCoordinator + +REDACT_CONFIG = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return _async_get_diagnostics(hass, entry) + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + redacted_config = async_redact_data(entry.data, REDACT_CONFIG) + coordinator: ImapDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + data = { + "config": redacted_config, + "event": coordinator.diagnostics_data, + } + + return data diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index f906270b2f533c..367af73810b487 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -1,7 +1,6 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -101,7 +100,7 @@ async def async_update(self) -> None: try: await self._heater.update() - except (ClientResponseError, asyncio.TimeoutError) as err: + except (ClientResponseError, TimeoutError) as err: _LOGGER.warning("Update failed, message is: %s", err) else: diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index ad3f282eff727d..46cd5ecb6ca7af 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -3,6 +3,7 @@ "name": "InfluxDB", "codeowners": ["@mdegat01"], "documentation": "https://www.home-assistant.io/integrations/influxdb", + "import_executor": true, "iot_class": "local_push", "loggers": ["influxdb", "influxdb_client"], "requirements": ["influxdb==5.3.1", "influxdb-client==1.24.0"] diff --git a/homeassistant/components/inspired_shades/__init__.py b/homeassistant/components/inspired_shades/__init__.py new file mode 100644 index 00000000000000..d14277a46b3554 --- /dev/null +++ b/homeassistant/components/inspired_shades/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Inspired shades.""" diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index e960b5616cba2c..f307208e537b82 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,9 +10,11 @@ from homeassistant.components import http from homeassistant.components.cover import ( + ATTR_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, ) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.lock import ( @@ -20,6 +22,12 @@ SERVICE_LOCK, SERVICE_UNLOCK, ) +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -70,6 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, NevermindIntentHandler(), ) + intent.async_register(hass, SetPositionIntentHandler()) return True @@ -82,16 +91,18 @@ async def async_setup_intents(self, hass: HomeAssistant) -> None: class OnOffIntentHandler(intent.ServiceIntentHandler): - """Intent handler for on/off that handles covers too.""" + """Intent handler for on/off that also supports covers, valves, locks, etc.""" - async def async_call_service(self, intent_obj: intent.Intent, state: State) -> None: - """Call service on entity with special case for covers.""" + async def async_call_service( + self, domain: str, service: str, intent_obj: intent.Intent, state: State + ) -> None: + """Call service on entity with handling for special cases.""" hass = intent_obj.hass if state.domain == COVER_DOMAIN: # on = open # off = close - if self.service == SERVICE_TURN_ON: + if service == SERVICE_TURN_ON: service_name = SERVICE_OPEN_COVER else: service_name = SERVICE_CLOSE_COVER @@ -112,7 +123,7 @@ async def async_call_service(self, intent_obj: intent.Intent, state: State) -> N if state.domain == LOCK_DOMAIN: # on = lock # off = unlock - if self.service == SERVICE_TURN_ON: + if service == SERVICE_TURN_ON: service_name = SERVICE_LOCK else: service_name = SERVICE_UNLOCK @@ -130,13 +141,34 @@ async def async_call_service(self, intent_obj: intent.Intent, state: State) -> N ) return - if not hass.services.has_service(state.domain, self.service): + if state.domain == VALVE_DOMAIN: + # on = opened + # off = closed + if service == SERVICE_TURN_ON: + service_name = SERVICE_OPEN_VALVE + else: + service_name = SERVICE_CLOSE_VALVE + + await self._run_then_background( + hass.async_create_task( + hass.services.async_call( + VALVE_DOMAIN, + service_name, + {ATTR_ENTITY_ID: state.entity_id}, + context=intent_obj.context, + blocking=True, + ) + ) + ) + return + + if not hass.services.has_service(state.domain, service): raise intent.IntentHandleError( - f"Service {self.service} does not support entity {state.entity_id}" + f"Service {service} does not support entity {state.entity_id}" ) # Fall back to homeassistant.turn_on/off - await super().async_call_service(intent_obj, state) + await super().async_call_service(domain, service, intent_obj, state) class GetStateIntentHandler(intent.IntentHandler): @@ -270,6 +302,29 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse return intent_obj.create_response() +class SetPositionIntentHandler(intent.DynamicServiceIntentHandler): + """Intent handler for setting positions.""" + + def __init__(self) -> None: + """Create set position handler.""" + super().__init__( + intent.INTENT_SET_POSITION, + extra_slots={ATTR_POSITION: vol.All(vol.Range(min=0, max=100))}, + ) + + def get_domain_and_service( + self, intent_obj: intent.Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + if state.domain == COVER_DOMAIN: + return (COVER_DOMAIN, SERVICE_SET_COVER_POSITION) + + if state.domain == VALVE_DOMAIN: + return (VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION) + + raise intent.IntentHandleError(f"Domain not supported: {state.domain}") + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 6c6642a0226db0..59a9d499d939ad 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -52,13 +52,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up iOS from a config entry.""" - entities = [ + async_add_entities( IOSSensor(device_name, device, description) for device_name, device in ios.devices(hass).items() for description in SENSOR_TYPES - ] - - async_add_entities(entities, True) + ) class IOSSensor(SensorEntity): diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py index 4cb8f921ba44bf..7668802c9e063b 100644 --- a/homeassistant/components/ipma/__init__.py +++ b/homeassistant/components/ipma/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: async with asyncio.timeout(30): location = await Location.get(api, float(latitude), float(longitude)) - except (IPMAException, asyncio.TimeoutError) as err: + except (IPMAException, TimeoutError) as err: raise ConfigEntryNotReady( f"Could not get location for ({latitude},{longitude})" ) from err diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index f9b93cbe954142..866f44f06171ee 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -217,7 +217,7 @@ async def _try_update_forecast( period: int, ) -> None: """Try to update weather forecast.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(10): await self._update_forecast(forecast_type, period, False) diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index cedf0521f9550d..3625a2d867eff8 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.4"], + "requirements": ["pyipp==0.14.5"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 0052e90880b18a..d17a278a106b14 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -232,15 +232,11 @@ def update_from_latest_data(self) -> None: if self.entity_description.key in ( TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, - ): - data = self.coordinator.data.get("Location") - elif self.entity_description.key in ( TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW, + TYPE_DISEASE_TODAY, ): data = self.coordinator.data.get("Location") - elif self.entity_description.key == TYPE_DISEASE_TODAY: - data = self.coordinator.data.get("Location") except KeyError: return diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 86ef3ce271f7ed..55e5618d9d4b71 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -62,10 +62,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> CONF_LONGITUDE: lon, } unique_id = f"{lat}-{lon}" - config_entry.version = 1 - config_entry.minor_version = 2 hass.config_entries.async_update_entry( - config_entry, data=new, unique_id=unique_id + config_entry, data=new, unique_id=unique_id, version=1, minor_version=2 ) _LOGGER.debug("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 2fde06f576d353..73696572593fa2 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -39,7 +39,7 @@ async def async_validate_location( - hass: HomeAssistant, lon: float, lat: float + hass: HomeAssistant, lat: float, lon: float ) -> dict[str, str]: """Check if the selected location is valid.""" errors = {} diff --git a/homeassistant/components/ismartwindow/__init__.py b/homeassistant/components/ismartwindow/__init__.py new file mode 100644 index 00000000000000..47aa71b3d9c5f0 --- /dev/null +++ b/homeassistant/components/ismartwindow/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: iSmartWindow.""" diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index c611bf83050ca9..0c5ea27a0b9eb9 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -102,7 +102,7 @@ async def async_setup_entry( try: async with asyncio.timeout(60): await isy.initialize() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady( "Timed out initializing the ISY; device may be busy, trying again later:" f" {err}" diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index 3aa81027b4f739..8c9815cd425b40 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -21,6 +21,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/isy994", + "import_executor": true, "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyisy"], diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 78fd8b2a5b658f..d73086f9ab11ee 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -65,9 +65,7 @@ def _request(self, method, path, params=None): try: if method == "GET": response = requests.get(url, timeout=DEFAULT_TIMEOUT) - elif method == "POST": - response = requests.put(url, params, timeout=DEFAULT_TIMEOUT) - elif method == "PUT": + elif method in ("POST", "PUT"): response = requests.put(url, params, timeout=DEFAULT_TIMEOUT) elif method == "DELETE": response = requests.delete(url, timeout=DEFAULT_TIMEOUT) diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 8e6fe584456744..d56fb93d4e6999 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -25,7 +25,7 @@ def dispatch_discovered(_): disco = await async_start_discovery_service(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(TIMEOUT_DISCOVERY): await controller_ready.wait() diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index d3291e51bc1827..550ca2d9e5dee4 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType DOMAIN = "jewish_calendar" -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] CONF_DIASPORA = "diaspora" CONF_LANGUAGE = "language" diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index bcefe763e159fb..820f0d1fcc031e 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,107 +1,37 @@ """The JuiceNet integration.""" -from datetime import timedelta -import logging +from __future__ import annotations -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .device import JuiceNetApi - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) - +from homeassistant.helpers import issue_registry as ir -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the JuiceNet component.""" - conf = config.get(DOMAIN) - hass.data.setdefault(DOMAIN, {}) - - if not conf: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - return True +DOMAIN = "juicenet" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JuiceNet from a config entry.""" - - config = entry.data - - session = async_get_clientsession(hass) - - access_token = config[CONF_ACCESS_TOKEN] - api = Api(access_token, session) - - juicenet = JuiceNetApi(api) - - try: - await juicenet.setup() - except TokenError as error: - _LOGGER.error("JuiceNet Error %s", error) - return False - except aiohttp.ClientError as error: - _LOGGER.error("Could not reach the JuiceNet API %s", error) - raise ConfigEntryNotReady from error - - if not juicenet.devices: - _LOGGER.error("No JuiceNet devices found for this account") - return False - _LOGGER.info("%d JuiceNet device(s) found", len(juicenet.devices)) - - async def async_update_data(): - """Update all device states from the JuiceNet API.""" - for device in juicenet.devices: - await device.update_state(True) - return True - - coordinator = DataUpdateCoordinator( + ir.async_create_issue( hass, - _LOGGER, - name="JuiceNet", - update_method=async_update_data, - update_interval=timedelta(seconds=30), + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/juicenet", + }, ) - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = { - JUICENET_API: juicenet, - JUICENET_COORDINATOR: coordinator, - } - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + + return True diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 35c1853b974fc5..7fdc024df47c9e 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -1,77 +1,11 @@ """Config flow for JuiceNet integration.""" -import logging -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol +from homeassistant import config_entries -from homeassistant import config_entries, core, exceptions -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - session = async_get_clientsession(hass) - juicenet = Api(data[CONF_ACCESS_TOKEN], session) - - try: - await juicenet.get_devices() - except TokenError as error: - _LOGGER.error("Token Error %s", error) - raise InvalidAuth from error - except aiohttp.ClientError as error: - _LOGGER.error("Error connecting %s", error) - raise CannotConnect from error - - # Return info that you want to store in the config entry. - return {"title": "JuiceNet"} +from . import DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for JuiceNet.""" VERSION = 1 - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) - self._abort_if_unique_id_configured() - - try: - info = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - async def async_step_import(self, user_input): - """Handle import.""" - return await self.async_step_user(user_input) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/juicenet/const.py b/homeassistant/components/juicenet/const.py deleted file mode 100644 index 5dc3e5c3e27582..00000000000000 --- a/homeassistant/components/juicenet/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants used by the JuiceNet component.""" - -DOMAIN = "juicenet" - -JUICENET_API = "juicenet_api" -JUICENET_COORDINATOR = "juicenet_coordinator" diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py deleted file mode 100644 index 86e1c92e4da809..00000000000000 --- a/homeassistant/components/juicenet/device.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - - -class JuiceNetApi: - """Represent a connection to JuiceNet.""" - - def __init__(self, api): - """Create an object from the provided API instance.""" - self.api = api - self._devices = [] - - async def setup(self): - """JuiceNet device setup.""" # noqa: D403 - self._devices = await self.api.get_devices() - - @property - def devices(self) -> list: - """Get a list of devices managed by this account.""" - return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py deleted file mode 100644 index b3433948582819..00000000000000 --- a/homeassistant/components/juicenet/entity.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - -from pyjuicenet import Charger - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import DOMAIN - - -class JuiceNetDevice(CoordinatorEntity): - """Represent a base JuiceNet device.""" - - _attr_has_entity_name = True - - def __init__( - self, device: Charger, key: str, coordinator: DataUpdateCoordinator - ) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.device = device - self.key = key - self._attr_unique_id = f"{device.id}-{key}" - self._attr_device_info = DeviceInfo( - configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={device.id}" - ), - identifiers={(DOMAIN, device.id)}, - manufacturer="JuiceNet", - name=device.name, - ) diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json index 979e540af014d2..5bdad83ac1ec52 100644 --- a/homeassistant/components/juicenet/manifest.json +++ b/homeassistant/components/juicenet/manifest.json @@ -1,10 +1,9 @@ { "domain": "juicenet", "name": "JuiceNet", - "codeowners": ["@jesserockz"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/juicenet", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyjuicenet"], - "requirements": ["python-juicenet==1.1.0"] + "requirements": [] } diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py deleted file mode 100644 index fd2535c5bf391d..00000000000000 --- a/homeassistant/components/juicenet/number.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers.""" -from __future__ import annotations - -from dataclasses import dataclass - -from pyjuicenet import Api, Charger - -from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, - NumberEntity, - NumberEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice - - -@dataclass(frozen=True) -class JuiceNetNumberEntityDescriptionMixin: - """Mixin for required keys.""" - - setter_key: str - - -@dataclass(frozen=True) -class JuiceNetNumberEntityDescription( - NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin -): - """An entity description for a JuiceNetNumber.""" - - native_max_value_key: str | None = None - - -NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( - JuiceNetNumberEntityDescription( - translation_key="amperage_limit", - key="current_charging_amperage_limit", - native_min_value=6, - native_max_value_key="max_charging_amperage", - native_step=1, - setter_key="set_charging_amperage_limit", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the JuiceNet Numbers.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: Api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetNumber(device, description, coordinator) - for device in api.devices - for description in NUMBER_TYPES - ] - async_add_entities(entities) - - -class JuiceNetNumber(JuiceNetDevice, NumberEntity): - """Implementation of a JuiceNet number.""" - - entity_description: JuiceNetNumberEntityDescription - - def __init__( - self, - device: Charger, - description: JuiceNetNumberEntityDescription, - coordinator: DataUpdateCoordinator, - ) -> None: - """Initialise the number.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def native_value(self) -> float | None: - """Return the value of the entity.""" - return getattr(self.device, self.entity_description.key, None) - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - if self.entity_description.native_max_value_key is not None: - return getattr(self.device, self.entity_description.native_max_value_key) - if self.entity_description.native_max_value is not None: - return self.entity_description.native_max_value - return DEFAULT_MAX_VALUE - - async def async_set_native_value(self, value: float) -> None: - """Update the current value.""" - await getattr(self.device, self.entity_description.setter_key)(value) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py deleted file mode 100644 index 5f71e066b9c24b..00000000000000 --- a/homeassistant/components/juicenet/sensor.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" -from __future__ import annotations - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="status", - name="Charging Status", - ), - SensorEntityDescription( - key="temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - ), - SensorEntityDescription( - key="amps", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="charge_time", - translation_key="charge_time", - native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:timer-outline", - ), - SensorEntityDescription( - key="energy_added", - translation_key="energy_added", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the JuiceNet Sensors.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetSensorDevice(device, coordinator, description) - for device in api.devices - for description in SENSOR_TYPES - ] - async_add_entities(entities) - - -class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): - """Implementation of a JuiceNet sensor.""" - - def __init__( - self, device, coordinator, description: SensorEntityDescription - ) -> None: - """Initialise the sensor.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def icon(self): - """Return the icon of the sensor.""" - icon = None - if self.entity_description.key == "status": - status = self.device.status - if status == "standby": - icon = "mdi:power-plug-off" - elif status == "plugged": - icon = "mdi:power-plug" - elif status == "charging": - icon = "mdi:battery-positive" - else: - icon = self.entity_description.icon - return icon - - @property - def native_value(self): - """Return the state.""" - return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json index 0e3732c66d2bfa..6e25130955b11b 100644 --- a/homeassistant/components/juicenet/strings.json +++ b/homeassistant/components/juicenet/strings.json @@ -1,41 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "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%]" - }, - "step": { - "user": { - "data": { - "api_token": "[%key:common::config_flow::data::api_token%]" - }, - "description": "You will need the API Token from https://home.juice.net/Manage.", - "title": "Connect to JuiceNet" - } - } - }, - "entity": { - "number": { - "amperage_limit": { - "name": "Amperage limit" - } - }, - "sensor": { - "charge_time": { - "name": "Charge time" - }, - "energy_added": { - "name": "Energy added" - } - }, - "switch": { - "charge_now": { - "name": "Charge now" - } + "issues": { + "integration_removed": { + "title": "The JuiceNet integration has been removed", + "description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})." } } } diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py deleted file mode 100644 index 7c373eeeb245b7..00000000000000 --- a/homeassistant/components/juicenet/switch.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches.""" -from typing import Any - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the JuiceNet switches.""" - entities = [] - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] - - for device in api.devices: - entities.append(JuiceNetChargeNowSwitch(device, coordinator)) - async_add_entities(entities) - - -class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): - """Implementation of a JuiceNet switch.""" - - _attr_translation_key = "charge_now" - - def __init__(self, device, coordinator): - """Initialise the switch.""" - super().__init__(device, "charge_now", coordinator) - - @property - def is_on(self): - """Return true if switch is on.""" - return self.device.override_time != 0 - - async def async_turn_on(self, **kwargs: Any) -> None: - """Charge now.""" - await self.device.set_override(True) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Don't charge now.""" - await self.device.set_override(False) diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py index 09d470af1deedd..6ee73b8ace70f6 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -54,7 +54,7 @@ async def async_update(self) -> None: try: async with asyncio.timeout(10): data = await self._api.get_latest_sensor_readings(self._devices) - except (ClientResponseError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ClientResponseError, ClientConnectorError, TimeoutError) as err: _LOGGER.debug("Couldn't fetch data from Kaiterra API: %s", err) self.data = {} async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 207c9e353a15b9..6f33b11742a5c6 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -75,11 +75,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> for mac, device in router.last_devices.items() if device.interface in new_tracked_interfaces } - for entity_entry in list(ent_reg.entities.values()): - if ( - entity_entry.config_entry_id == config_entry.entry_id - and entity_entry.domain == Platform.DEVICE_TRACKER - ): + for entity_entry in ent_reg.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity_entry.domain == Platform.DEVICE_TRACKER: mac = entity_entry.unique_id.partition("_")[0] if mac not in keep_devices: _LOGGER.debug("Removing entity %s", entity_entry.entity_id) diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index c51d30431be8d6..c9e81071ad705c 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -43,11 +43,10 @@ def update_from_router(): registry = er.async_get(hass) # Restore devices that are not a part of active clients list. restored = [] - for entity_entry in registry.entities.values(): - if ( - entity_entry.config_entry_id == config_entry.entry_id - and entity_entry.domain == DEVICE_TRACKER_DOMAIN - ): + for entity_entry in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity_entry.domain == DEVICE_TRACKER_DOMAIN: mac = entity_entry.unique_id.partition("_")[0] if mac not in tracked: tracked.add(mac) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 05e06d819f154b..5abdfe5b4a79d5 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.12"] + "requirements": ["PyMicroBot==0.0.17"] } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 8369892be85567..228803097d66b6 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,10 +27,12 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ + Platform.BUTTON, Platform.IMAGE, Platform.LAWN_MOWER, Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, Platform.WEATHER, ] diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py new file mode 100644 index 00000000000000..cdc0cebb348a84 --- /dev/null +++ b/homeassistant/components/kitchen_sink/button.py @@ -0,0 +1,62 @@ +"""Demo platform that offers a fake button entity.""" +from __future__ import annotations + +from homeassistant.components import persistent_notification +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo button platform.""" + async_add_entities( + [ + DemoButton( + unique_id="2_ch_power_strip", + device_name=None, + device_translation_key="n_ch_power_strip", + device_translation_placeholders={"number_of_sockets": "2"}, + entity_name="Restart", + ), + ] + ) + + +class DemoButton(ButtonEntity): + """Representation of a demo button entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + device_name: str | None, + device_translation_key: str | None, + device_translation_placeholders: dict[str, str] | None, + entity_name: str | None, + ) -> None: + """Initialize the Demo button entity.""" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + translation_key=device_translation_key, + translation_placeholders=device_translation_placeholders, + ) + self._attr_name = entity_name + + async def async_press(self) -> None: + """Send out a persistent notification.""" + persistent_notification.async_create( + self.hass, "Button pressed", title="Button" + ) + self.hass.bus.async_fire("demo_button_pressed") diff --git a/homeassistant/components/kitchen_sink/device.py b/homeassistant/components/kitchen_sink/device.py new file mode 100644 index 00000000000000..fef41f7917c7d9 --- /dev/null +++ b/homeassistant/components/kitchen_sink/device.py @@ -0,0 +1,27 @@ +"""Create device without entities.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import DOMAIN + + +def async_create_device( + hass: HomeAssistant, + config_entry_id: str, + device_name: str | None, + device_translation_key: str | None, + device_translation_placeholders: dict[str, str] | None, + unique_id: str, +) -> dr.DeviceEntry: + """Create a device.""" + device_registry = dr.async_get(hass) + return device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, unique_id)}, + name=device_name, + translation_key=device_translation_key, + translation_placeholders=device_translation_placeholders, + ) diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py index 4e1e3bd2010d12..4800104d17d25e 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -11,9 +11,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType from . import DOMAIN +from .device import async_create_device async def async_setup_entry( @@ -22,31 +23,68 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Everything but the Kitchen Sink config entry.""" + async_create_device( + hass, + config_entry.entry_id, + None, + "n_ch_power_strip", + {"number_of_sockets": "2"}, + "2_ch_power_strip", + ) + async_add_entities( [ DemoSensor( - "statistics_issue_1", - "Statistics issue 1", - 100, - None, - SensorStateClass.MEASUREMENT, - UnitOfPower.WATT, # Not a volume unit + device_unique_id="outlet_1", + unique_id="outlet_1_power", + device_name="Outlet 1", + entity_name=UNDEFINED, + state=50, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", + ), + DemoSensor( + device_unique_id="outlet_2", + unique_id="outlet_2_power", + device_name="Outlet 2", + entity_name=UNDEFINED, + state=1500, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, + via_device="2_ch_power_strip", + ), + DemoSensor( + device_unique_id="statistics_issues", + unique_id="statistics_issue_1", + device_name="Statistics issues", + entity_name="Issue 1", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement=UnitOfPower.WATT, ), DemoSensor( - "statistics_issue_2", - "Statistics issue 2", - 100, - None, - SensorStateClass.MEASUREMENT, - "dogs", # Can't be converted to cats + device_unique_id="statistics_issues", + unique_id="statistics_issue_2", + device_name="Statistics issues", + entity_name="Issue 2", + state=100, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + unit_of_measurement="dogs", ), DemoSensor( - "statistics_issue_3", - "Statistics issue 3", - 100, - None, - None, # Wrong state class - UnitOfPower.WATT, + device_unique_id="statistics_issues", + unique_id="statistics_issue_3", + device_name="Statistics issues", + entity_name="Issue 3", + state=100, + device_class=None, + state_class=None, + unit_of_measurement=UnitOfPower.WATT, ), ] ) @@ -55,26 +93,34 @@ async def async_setup_entry( class DemoSensor(SensorEntity): """Representation of a Demo sensor.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__( self, + *, + device_unique_id: str, unique_id: str, - name: str, + device_name: str, + entity_name: str | None | UndefinedType, state: StateType, device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, unit_of_measurement: str | None, + via_device: str | None = None, ) -> None: """Initialize the sensor.""" self._attr_device_class = device_class - self._attr_name = name + if entity_name is not UNDEFINED: + self._attr_name = entity_name self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_value = state self._attr_state_class = state_class self._attr_unique_id = unique_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, - name=name, + identifiers={(DOMAIN, device_unique_id)}, + name=device_name, ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index dca42ce8361bca..ecfbe406aab523 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -6,6 +6,11 @@ } } }, + "device": { + "n_ch_power_strip": { + "name": "Power strip with {number_of_sockets} sockets" + } + }, "issues": { "bad_psu": { "title": "The power supply is not stable", diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py new file mode 100644 index 00000000000000..e60de2f09c83f6 --- /dev/null +++ b/homeassistant/components/kitchen_sink/switch.py @@ -0,0 +1,93 @@ +"""Demo platform that has some fake switches.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN +from .device import async_create_device + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo switch platform.""" + async_create_device( + hass, + config_entry.entry_id, + None, + "n_ch_power_strip", + {"number_of_sockets": "2"}, + "2_ch_power_strip", + ) + + async_add_entities( + [ + DemoSwitch( + unique_id="outlet_1", + device_name="Outlet 1", + entity_name=None, + state=False, + assumed=False, + via_device="2_ch_power_strip", + ), + DemoSwitch( + unique_id="outlet_2", + device_name="Outlet 2", + entity_name=None, + state=True, + assumed=False, + via_device="2_ch_power_strip", + ), + ] + ) + + +class DemoSwitch(SwitchEntity): + """Representation of a demo switch.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + *, + unique_id: str, + device_name: str, + entity_name: str | None, + state: bool, + assumed: bool, + translation_key: str | None = None, + device_class: SwitchDeviceClass | None = None, + via_device: str | None = None, + ) -> None: + """Initialize the Demo switch.""" + self._attr_assumed_state = assumed + self._attr_device_class = device_class + self._attr_translation_key = translation_key + self._attr_is_on = state + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) + if via_device: + self._attr_device_info["via_device"] = (DOMAIN, via_device) + self._attr_name = entity_name + + def turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._attr_is_on = True + self.schedule_update_ha_state() + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._attr_is_on = False + self.schedule_update_ha_state() diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 6e3da8ad5233e7..5338a5fddca2d3 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -57,7 +57,7 @@ KNXConfigEntryData, ) from .helpers.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file -from .schema import ia_validator, ip_v4_validator +from .validation import ia_validator, ip_v4_validator CONF_KNX_GATEWAY: Final = "gateway" CONF_MAX_RATE_LIMIT: Final = 60 @@ -292,9 +292,7 @@ async def async_step_manual_tunnel( else: if bool(self._selected_tunnel.tunnelling_requires_secure) is not ( selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE - ): - errors[CONF_KNX_TUNNELING_TYPE] = "unsupported_tunnel_type" - elif ( + ) or ( selected_tunnelling_type == CONF_KNX_TUNNELING_TCP and not self._selected_tunnel.supports_tunnelling_tcp ): diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 6a304f7de5f86d..290b560dad5b04 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,8 +11,8 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.12.0", - "xknxproject==3.6.0", + "xknx==2.12.2", + "xknxproject==3.7.0", "knx-frontend==2024.1.20.105944" ] } diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c7bcd90538f0d1..d559cd2005a448 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -3,15 +3,12 @@ from abc import ABC from collections import OrderedDict -from collections.abc import Callable -import ipaddress -from typing import Any, ClassVar, Final +from typing import ClassVar, Final import voluptuous as vol from xknx.devices.climate import SetpointShiftMode -from xknx.dpt import DPTBase, DPTNumeric, DPTString -from xknx.exceptions import ConversionError, CouldNotParseAddress, CouldNotParseTelegram -from xknx.telegram.address import IndividualAddress, parse_device_group_address +from xknx.dpt import DPTBase, DPTNumeric +from xknx.exceptions import ConversionError, CouldNotParseTelegram from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, @@ -57,83 +54,19 @@ PRESET_MODES, ColorTempModes, ) - -################## -# KNX VALIDATORS -################## - - -def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: - """Validate that value is parsable as given sensor type.""" - - def dpt_value_validator(value: Any) -> str | int: - """Validate that value is parsable as sensor type.""" - if ( - isinstance(value, (str, int)) - and dpt_base_class.parse_transcoder(value) is not None - ): - return value - raise vol.Invalid( - f"type '{value}' is not a valid DPT identifier for" - f" {dpt_base_class.__name__}." - ) - - return dpt_value_validator - - -numeric_type_validator = dpt_subclass_validator(DPTNumeric) # type: ignore[type-abstract] -sensor_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract] -string_type_validator = dpt_subclass_validator(DPTString) - - -def ga_validator(value: Any) -> str | int: - """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" - if isinstance(value, (str, int)): - try: - parse_device_group_address(value) - return value - except CouldNotParseAddress: - pass - raise vol.Invalid( - f"value '{value}' is not a valid KNX group address '
//'," - " '
/' or '' (eg.'1/2/3', '9/234', '123'), nor xknx internal" - " address 'i-'." - ) - - -ga_list_validator = vol.All( - cv.ensure_list, - [ga_validator], - vol.IsTrue("value must be a group address or a list containing group addresses"), +from .validation import ( + ga_list_validator, + ga_validator, + numeric_type_validator, + sensor_type_validator, + string_type_validator, + sync_state_validator, ) -ia_validator = vol.Any( - vol.All(str, str.strip, cv.matches_regex(IndividualAddress.ADDRESS_RE.pattern)), - vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), - msg=( - "value does not match pattern for KNX individual address" - " '..' (eg.'1.1.100')" - ), -) - - -def ip_v4_validator(value: Any, multicast: bool | None = None) -> str: - """Validate that value is parsable as IPv4 address. - - Optionally check if address is in a reserved multicast block or is explicitly not. - """ - try: - address = ipaddress.IPv4Address(value) - except ipaddress.AddressValueError as ex: - raise vol.Invalid(f"value '{value}' is not a valid IPv4 address: {ex}") from ex - if multicast is not None and address.is_multicast != multicast: - raise vol.Invalid( - f"value '{value}' is not a valid IPv4" - f" {'multicast' if multicast else 'unicast'} address" - ) - return str(address) - +################## +# KNX SUB VALIDATORS +################## def number_limit_sub_validator(entity_config: OrderedDict) -> OrderedDict: """Validate a number entity configurations dependent on configured value type.""" value_type = entity_config[CONF_TYPE] @@ -227,12 +160,6 @@ def select_options_sub_validator(entity_config: OrderedDict) -> OrderedDict: return entity_config -sync_state_validator = vol.Any( - vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), - cv.boolean, - cv.matches_regex(r"^(init|expire|every)( \d*)?$"), -) - ######### # EVENT ######### @@ -264,7 +191,7 @@ class KNXPlatformSchema(ABC): """Voluptuous schema for KNX platform entity configuration.""" PLATFORM: ClassVar[Platform | str] - ENTITY_SCHEMA: ClassVar[vol.Schema] + ENTITY_SCHEMA: ClassVar[vol.Schema | vol.All | vol.Any] @classmethod def platform_node(cls) -> dict[vol.Optional, vol.All]: @@ -518,18 +445,6 @@ class CoverSchema(KNXPlatformSchema): DEFAULT_NAME = "KNX Cover" ENTITY_SCHEMA = vol.All( - vol.Schema( - { - vol.Required( - vol.Any(CONF_MOVE_LONG_ADDRESS, CONF_POSITION_ADDRESS), - msg=( - f"At least one of '{CONF_MOVE_LONG_ADDRESS}' or" - f" '{CONF_POSITION_ADDRESS}' is required." - ), - ): object, - }, - extra=vol.ALLOW_EXTRA, - ), vol.Schema( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -553,6 +468,20 @@ class CoverSchema(KNXPlatformSchema): vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, } ), + vol.Any( + vol.Schema( + {vol.Required(CONF_MOVE_LONG_ADDRESS): object}, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( + {vol.Required(CONF_POSITION_ADDRESS): object}, + extra=vol.ALLOW_EXTRA, + ), + msg=( + f"At least one of '{CONF_MOVE_LONG_ADDRESS}' or" + f" '{CONF_POSITION_ADDRESS}' is required." + ), + ), ) diff --git a/homeassistant/components/knx/validation.py b/homeassistant/components/knx/validation.py new file mode 100644 index 00000000000000..c0ac93d19eb1e3 --- /dev/null +++ b/homeassistant/components/knx/validation.py @@ -0,0 +1,89 @@ +"""Validation helpers for KNX config schemas.""" +from collections.abc import Callable +import ipaddress +from typing import Any + +import voluptuous as vol +from xknx.dpt import DPTBase, DPTNumeric, DPTString +from xknx.exceptions import CouldNotParseAddress +from xknx.telegram.address import IndividualAddress, parse_device_group_address + +import homeassistant.helpers.config_validation as cv + + +def dpt_subclass_validator(dpt_base_class: type[DPTBase]) -> Callable[[Any], str | int]: + """Validate that value is parsable as given sensor type.""" + + def dpt_value_validator(value: Any) -> str | int: + """Validate that value is parsable as sensor type.""" + if ( + isinstance(value, (str, int)) + and dpt_base_class.parse_transcoder(value) is not None + ): + return value + raise vol.Invalid( + f"type '{value}' is not a valid DPT identifier for" + f" {dpt_base_class.__name__}." + ) + + return dpt_value_validator + + +numeric_type_validator = dpt_subclass_validator(DPTNumeric) # type: ignore[type-abstract] +sensor_type_validator = dpt_subclass_validator(DPTBase) # type: ignore[type-abstract] +string_type_validator = dpt_subclass_validator(DPTString) + + +def ga_validator(value: Any) -> str | int: + """Validate that value is parsable as GroupAddress or InternalGroupAddress.""" + if isinstance(value, (str, int)): + try: + parse_device_group_address(value) + return value + except CouldNotParseAddress as exc: + raise vol.Invalid( + f"'{value}' is not a valid KNX group address: {exc.message}" + ) from exc + raise vol.Invalid( + f"'{value}' is not a valid KNX group address: Invalid type '{type(value).__name__}'" + ) + + +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)), + vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)), + msg=( + "value does not match pattern for KNX individual address" + " '..' (eg.'1.1.100')" + ), +) + + +def ip_v4_validator(value: Any, multicast: bool | None = None) -> str: + """Validate that value is parsable as IPv4 address. + + Optionally check if address is in a reserved multicast block or is explicitly not. + """ + try: + address = ipaddress.IPv4Address(value) + except ipaddress.AddressValueError as ex: + raise vol.Invalid(f"value '{value}' is not a valid IPv4 address: {ex}") from ex + if multicast is not None and address.is_multicast != multicast: + raise vol.Invalid( + f"value '{value}' is not a valid IPv4" + f" {'multicast' if multicast else 'unicast'} address" + ) + return str(address) + + +sync_state_validator = vol.Any( + vol.All(vol.Coerce(int), vol.Range(min=2, max=1440)), + cv.boolean, + cv.matches_regex(r"^(init|expire|every)( \d*)?$"), +) diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index ba8e762763dc53..8dd3a8235702a7 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Kostal Plenticore Solar Inverter integration.""" -import asyncio import logging from aiohttp.client_exceptions import ClientError @@ -57,7 +56,7 @@ async def async_step_user(self, user_input=None): except AuthenticationException as ex: errors[CONF_PASSWORD] = "invalid_auth" _LOGGER.error("Error response: %s", ex) - except (ClientError, asyncio.TimeoutError): + except (ClientError, TimeoutError): errors[CONF_HOST] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index c3228e1d44947d..a04415a4f31e10 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -1,7 +1,6 @@ """Code to handle the Plenticore API.""" from __future__ import annotations -import asyncio from collections import defaultdict from collections.abc import Callable, Mapping from datetime import datetime, timedelta @@ -66,7 +65,7 @@ async def async_setup(self) -> bool: "Authentication exception connecting to %s: %s", self.host, err ) return False - except (ClientError, asyncio.TimeoutError) as err: + except (ClientError, TimeoutError) as err: _LOGGER.error("Error connecting to %s", self.host) raise ConfigEntryNotReady from err else: diff --git a/homeassistant/components/krispol/__init__.py b/homeassistant/components/krispol/__init__.py new file mode 100644 index 00000000000000..6d85da719919d9 --- /dev/null +++ b/homeassistant/components/krispol/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Krispol.""" diff --git a/homeassistant/components/krispol/manifest.json b/homeassistant/components/krispol/manifest.json new file mode 100644 index 00000000000000..fe60f2fab0edf9 --- /dev/null +++ b/homeassistant/components/krispol/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "krispol", + "name": "Krispol", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 0adfc4bebfe161..0cdacc8d2e4534 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -10,6 +10,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CALENDAR, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py new file mode 100644 index 00000000000000..2a08a90a1b2a0c --- /dev/null +++ b/homeassistant/components/lamarzocco/calendar.py @@ -0,0 +1,113 @@ +"""Calendar platform for La Marzocco espresso machines.""" + +from collections.abc import Iterator +from datetime import datetime, timedelta + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .entity import LaMarzoccoBaseEntity + +CALENDAR_KEY = "auto_on_off_schedule" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch entities and services.""" + + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY)]) + + +class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity): + """Class representing a La Marzocco calendar.""" + + _attr_translation_key = CALENDAR_KEY + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + now = dt_util.now() + + events = self._get_events( + start_date=now, + end_date=now + timedelta(days=7), # only need to check a week ahead + ) + return next(iter(events), None) + + async def async_get_events( + self, + hass: HomeAssistant, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range.""" + + return self._get_events( + start_date=start_date, + end_date=end_date, + ) + + def _get_events( + self, + start_date: datetime, + end_date: datetime, + ) -> list[CalendarEvent]: + """Get calendar events within a datetime range.""" + + events: list[CalendarEvent] = [] + for date in self._get_date_range(start_date, end_date): + if scheduled := self._async_get_calendar_event(date): + if scheduled.end < start_date: + continue + if scheduled.start > end_date: + continue + events.append(scheduled) + return events + + def _get_date_range( + self, start_date: datetime, end_date: datetime + ) -> Iterator[datetime]: + current_date = start_date + while current_date.date() < end_date.date(): + yield current_date + current_date += timedelta(days=1) + yield end_date + + def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None: + """Return calendar event for a given weekday.""" + + # check first if auto/on off is turned on in general + # because could still be on for that day but disabled + if self.coordinator.lm.current_status["global_auto"] != "Enabled": + return None + + # parse the schedule for the day + schedule_day = self.coordinator.lm.schedule[date.weekday()] + if schedule_day["enable"] == "Disabled": + return None + hour_on, minute_on = schedule_day["on"].split(":") + hour_off, minute_off = schedule_day["off"].split(":") + return CalendarEvent( + start=date.replace( + hour=int(hour_on), + minute=int(minute_on), + second=0, + microsecond=0, + ), + end=date.replace( + hour=int(hour_off), + minute=int(minute_off), + second=0, + microsecond=0, + ), + summary=f"Machine {self.coordinator.config_entry.title} on", + description="Machine is scheduled to turn on at the start time and off at the end time", + ) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6918741f1d3588..4cb9d4a580a221 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -21,29 +21,20 @@ class LaMarzoccoEntityDescription(EntityDescription): supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True -class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): +class LaMarzoccoBaseEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): """Common elements for all entities.""" - entity_description: LaMarzoccoEntityDescription _attr_has_entity_name = True - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.entity_description.available_fn( - self.coordinator.lm - ) - def __init__( self, coordinator: LaMarzoccoUpdateCoordinator, - entity_description: LaMarzoccoEntityDescription, + key: str, ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self.entity_description = entity_description lm = coordinator.lm - self._attr_unique_id = f"{lm.serial_number}_{entity_description.key}" + self._attr_unique_id = f"{lm.serial_number}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, lm.serial_number)}, name=lm.machine_name, @@ -52,3 +43,26 @@ def __init__( serial_number=lm.serial_number, sw_version=lm.firmware_version, ) + + +class LaMarzoccoEntity(LaMarzoccoBaseEntity): + """Common elements for all entities.""" + + entity_description: LaMarzoccoEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + entity_description: LaMarzoccoEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.lm + ) diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json index 70adfe95134d74..727d3c6600957a 100644 --- a/homeassistant/components/lamarzocco/icons.json +++ b/homeassistant/components/lamarzocco/icons.json @@ -25,9 +25,21 @@ "coffee_temp": { "default": "mdi:thermometer-water" }, + "dose": { + "default": "mdi:weight-kilogram" + }, "steam_temp": { "default": "mdi:thermometer-water" }, + "prebrew_off": { + "default": "mdi:water-off" + }, + "prebrew_on": { + "default": "mdi:water" + }, + "preinfusion_off": { + "default": "mdi:water" + }, "tea_water_duration": { "default": "mdi:timer-sand" } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index bf866872f5bd68..05f937f48f69b5 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -5,7 +5,7 @@ from typing import Any from lmcloud import LMCloud as LaMarzoccoClient -from lmcloud.const import LaMarzoccoModel +from lmcloud.const import KEYS_PER_MODEL, LaMarzoccoModel from homeassistant.components.number import ( NumberDeviceClass, @@ -16,6 +16,7 @@ from homeassistant.const import ( PRECISION_TENTHS, PRECISION_WHOLE, + EntityCategory, UnitOfTemperature, UnitOfTime, ) @@ -40,6 +41,19 @@ class LaMarzoccoNumberEntityDescription( ] +@dataclass(frozen=True, kw_only=True) +class LaMarzoccoKeyNumberEntityDescription( + LaMarzoccoEntityDescription, + NumberEntityDescription, +): + """Description of an La Marzocco number entity with keys.""" + + native_value_fn: Callable[[LaMarzoccoClient, int], float | int] + set_value_fn: Callable[ + [LaMarzoccoClient, float | int, int], Coroutine[Any, Any, bool] + ] + + ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( LaMarzoccoNumberEntityDescription( key="coffee_temp", @@ -89,6 +103,103 @@ class LaMarzoccoNumberEntityDescription( ) +async def _set_prebrew_on( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + on_time=int(value * 1000), + off_time=int(lm.current_status[f"prebrewing_toff_k{key}"] * 1000), + key=key, + ) + + +async def _set_prebrew_off( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + on_time=int(lm.current_status[f"prebrewing_ton_k{key}"] * 1000), + off_time=int(value * 1000), + key=key, + ) + + +async def _set_preinfusion( + lm: LaMarzoccoClient, + value: float, + key: int, +) -> bool: + return await lm.configure_prebrew( + off_time=int(value * 1000), + key=key, + ) + + +KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = ( + LaMarzoccoKeyNumberEntityDescription( + key="prebrew_off", + translation_key="prebrew_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=1, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_prebrew_off, + native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_ton_k{key}"], + available_fn=lambda lm: lm.current_status["enable_prebrewing"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="prebrew_on", + translation_key="prebrew_on", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=2, + native_max_value=10, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_prebrew_on, + native_value_fn=lambda lm, key: lm.current_status[f"prebrewing_toff_k{key}"], + available_fn=lambda lm: lm.current_status["enable_prebrewing"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="preinfusion_off", + translation_key="preinfusion_off", + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_step=PRECISION_TENTHS, + native_min_value=2, + native_max_value=29, + entity_category=EntityCategory.CONFIG, + set_value_fn=_set_preinfusion, + native_value_fn=lambda lm, key: lm.current_status[f"preinfusion_k{key}"], + available_fn=lambda lm: lm.current_status["enable_preinfusion"], + supported_fn=lambda coordinator: coordinator.lm.model_name + != LaMarzoccoModel.GS3_MP, + ), + LaMarzoccoKeyNumberEntityDescription( + key="dose", + translation_key="dose", + native_unit_of_measurement="ticks", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=999, + entity_category=EntityCategory.CONFIG, + set_value_fn=lambda lm, ticks, key: lm.set_dose(key=key, value=int(ticks)), + native_value_fn=lambda lm, key: lm.current_status[f"dose_k{key}"], + supported_fn=lambda coordinator: coordinator.lm.model_name + == LaMarzoccoModel.GS3_AV, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -103,6 +214,17 @@ async def async_setup_entry( if description.supported_fn(coordinator) ) + entities: list[LaMarzoccoKeyNumberEntity] = [] + for description in KEY_ENTITIES: + if description.supported_fn(coordinator): + num_keys = KEYS_PER_MODEL[coordinator.lm.model_name] + for key in range(min(num_keys, 1), num_keys + 1): + entities.append( + LaMarzoccoKeyNumberEntity(coordinator, description, key) + ) + + async_add_entities(entities) + class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): """La Marzocco number entity.""" @@ -118,3 +240,42 @@ async def async_set_native_value(self, value: float) -> None: """Set the value.""" await self.entity_description.set_value_fn(self.coordinator, value) self.async_write_ha_state() + + +class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity): + """Number representing espresso machine with key support.""" + + entity_description: LaMarzoccoKeyNumberEntityDescription + + def __init__( + self, + coordinator: LaMarzoccoUpdateCoordinator, + description: LaMarzoccoKeyNumberEntityDescription, + pyhsical_key: int, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, description) + + # Physical Key on the machine the entity represents. + if pyhsical_key == 0: + pyhsical_key = 1 + else: + self._attr_translation_key = f"{description.translation_key}_key" + self._attr_translation_placeholders = {"key": str(pyhsical_key)} + self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}" + self._attr_entity_registry_enabled_default = False + self.pyhsical_key = pyhsical_key + + @property + def native_value(self) -> float: + """Return the current value.""" + return self.entity_description.native_value_fn( + self.coordinator.lm, self.pyhsical_key + ) + + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn( + self.coordinator.lm, value, self.pyhsical_key + ) + self.async_write_ha_state() diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 7537405c6cdbaa..57421dfee83f14 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -56,10 +56,36 @@ "name": "Start backflush" } }, + "calendar": { + "auto_on_off_schedule": { + "name": "Auto on/off schedule" + } + }, "number": { "coffee_temp": { "name": "Coffee target temperature" }, + "dose_key": { + "name": "Dose Key {key}" + }, + "prebrew_on": { + "name": "Prebrew on time" + }, + "prebrew_on_key": { + "name": "Prebrew on time Key {key}" + }, + "prebrew_off": { + "name": "Prebrew off time" + }, + "prebrew_off_key": { + "name": "Prebrew off time Key {key}" + }, + "preinfusion_off": { + "name": "Preinfusion time" + }, + "preinfusion_off_key": { + "name": "Preinfusion time Key {key}" + }, "steam_temp": { "name": "Steam target temperature" }, diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 28317238bf9f80..101216cd0d4f34 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -49,7 +49,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Removing domain name and config entry id from entity unique id's, replacing it with device number if config_entry.version == 1: - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) device_number = config_entry.data["device_number"] diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 7d03ed2efafdf6..479e7107025334 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ async def validate_ultraheat(self, port: str) -> tuple[str, str]: # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.SerialException) as err: + except (TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 121d2cd913f38b..d67410c6aa39dc 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -34,7 +34,7 @@ def __init__( async def _async_update_data(self) -> dict[str, LaundrifyDevice]: """Fetch data from laundrify API.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(REQUEST_TIMEOUT): return {m["_id"]: m for m in await self.laundrify_api.get_machines()} diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 64a789f3a34b05..8cb0201033ebbc 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -286,7 +286,8 @@ def purge_device_registry( # Find all devices that are referenced in the entity registry. references_entities = { - entry.device_id for entry in entity_registry.entities.values() + entry.device_id + for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id) } # Find device that references the host. diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 70b77ba678720f..27a273ed7b09be 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -79,7 +79,7 @@ async def _async_update() -> None: try: async with asyncio.timeout(DEVICE_TIMEOUT): await startup_event.wait() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( "Unable to communicate with the device; " f"Try moving the Bluetooth adapter closer to {led_ble.name}" diff --git a/homeassistant/components/lg_soundbar/config_flow.py b/homeassistant/components/lg_soundbar/config_flow.py index d2cb1749689fe1..fde5c20ebd721c 100644 --- a/homeassistant/components/lg_soundbar/config_flow.py +++ b/homeassistant/components/lg_soundbar/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the LG Soundbar integration.""" import logging from queue import Empty, Full, Queue -import socket import temescal import voluptuous as vol @@ -60,7 +59,7 @@ def msg_callback(response): details["uuid"] = uuid_q.get(timeout=QUEUE_TIMEOUT) except Empty: pass - except socket.timeout as err: + except TimeoutError as err: raise ConnectionError(f"Connection timeout with server: {host}:{port}") from err except OSError as err: raise ConnectionError(f"Cannot resolve hostname: {host}") from err diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 54d9be78df9c53..cfd0ebbd7a7cc1 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -90,7 +90,7 @@ def _connect(self) -> None: def handle_event(self, response): """Handle responses from the speakers.""" - data = response["data"] if "data" in response else {} + data = response.get("data") or {} if response["msg"] == "EQ_VIEW_INFO": if "i_bass" in data: self._bass = data["i_bass"] diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py index 22ac66e3bc98cb..b6fd67c0356ad7 100644 --- a/homeassistant/components/lifx/config_flow.py +++ b/homeassistant/components/lifx/config_flow.py @@ -1,7 +1,6 @@ """Config flow flow LIFX.""" from __future__ import annotations -import asyncio import socket from typing import Any @@ -242,7 +241,7 @@ async def _async_try_connect( DEFAULT_ATTEMPTS, OVERALL_TIMEOUT, ) - except asyncio.TimeoutError: + except TimeoutError: return None finally: connection.async_stop() diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index e668a7ad79ad93..18a8a24cb9463e 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -315,7 +315,7 @@ async def async_get_extended_color_zones(self) -> None: """Get updated color information for all zones.""" try: await async_execute_lifx(self.device.get_extended_color_zones) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout getting color zones from {self.name}" ) from ex diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index e04e8afb3df63d..74ed209742ca3d 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -281,7 +281,7 @@ async def set_power( """Send a power change to the bulb.""" try: await self.coordinator.async_set_power(pwr, duration) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError(f"Timeout setting power for {self.name}") from ex async def set_color( @@ -294,7 +294,7 @@ async def set_color( merged_hsbk = merge_hsbk(self.bulb.color, hsbk) try: await self.coordinator.async_set_color(merged_hsbk, duration) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError(f"Timeout setting color for {self.name}") from ex async def get_color( @@ -303,7 +303,7 @@ async def get_color( """Send a get color message to the bulb.""" try: await self.coordinator.async_get_color() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting getting color for {self.name}" ) from ex @@ -429,7 +429,7 @@ async def set_color( await self.coordinator.async_set_color_zones( zone, zone, zone_hsbk, duration, apply ) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting color zones for {self.name}" ) from ex @@ -444,7 +444,7 @@ async def update_color_zones( """Send a get color zones message to the device.""" try: await self.coordinator.async_get_color_zones() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout getting color zones from {self.name}" ) from ex @@ -477,7 +477,7 @@ async def set_color( await self.coordinator.async_set_extended_color_zones( color_zones, duration=duration ) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise HomeAssistantError( f"Timeout setting color zones on {self.name}" ) from ex diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index feaeba8da8f1e7..5d41839f61de59 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -202,7 +202,7 @@ async def async_multi_execute_lifx_with_retries( a response again. If we don't get a result after all attempts, we will raise an - asyncio.TimeoutError exception. + TimeoutError exception. """ loop = asyncio.get_running_loop() futures: list[asyncio.Future] = [loop.create_future() for _ in methods] @@ -236,8 +236,6 @@ def _callback( if failed: failed_methods = ", ".join(failed) - raise asyncio.TimeoutError( - f"{failed_methods} timed out after {attempts} attempts" - ) + raise TimeoutError(f"{failed_methods} timed out after {attempts} attempts") return results diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index bcf8ed1dc2cc10..61656741f82627 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -50,7 +50,7 @@ async def async_setup_platform( async with asyncio.timeout(timeout): scenes_resp = await httpsession.get(url, headers=headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error on %s", url) return @@ -92,5 +92,5 @@ async def async_activate(self, **kwargs: Any) -> None: async with asyncio.timeout(self._timeout): await httpsession.put(url, headers=self._headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.exception("Error on %s", url) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 4abe18daa2110b..795975b5c3e695 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -694,7 +694,11 @@ def _coerce_none(value: str) -> None: @dataclasses.dataclass class Profile: - """Representation of a profile.""" + """Representation of a profile. + + The light profiles feature is in a frozen development state + until otherwise decided in an architecture discussion. + """ name: str color_x: float | None = dataclasses.field(repr=False) @@ -742,7 +746,11 @@ def from_csv_row(cls, csv_row: list[str]) -> Self: class Profiles: - """Representation of available color profiles.""" + """Representation of available color profiles. + + The light profiles feature is in a frozen development state + until otherwise decided in an architecture discussion. + """ def __init__(self, hass: HomeAssistant) -> None: """Initialize profiles.""" @@ -882,6 +890,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None + __color_mode_reported = False + @cached_property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" @@ -897,7 +907,20 @@ def _light_internal_color_mode(self) -> str: """Return the color mode of the light with backwards compatibility.""" if (color_mode := self.color_mode) is None: # Backwards compatibility for color_mode added in 2021.4 - # Add warning in 2024.3, remove in 2025.3 + # Warning added in 2024.3, break in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) does not report a color mode, this will stop working " + "in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) + supported = self._light_internal_supported_color_modes if ColorMode.HS in supported and self.hs_color is not None: @@ -1068,8 +1091,8 @@ def __validate_color_mode( effect: str | None, ) -> None: """Validate the color mode.""" - if color_mode is None: - # The light is turned off + if color_mode is None or color_mode == ColorMode.UNKNOWN: + # The light is turned off or in an unknown state return if not effect or effect == EFFECT_OFF: @@ -1077,13 +1100,22 @@ def __validate_color_mode( # color modes if color_mode in supported_color_modes: return - # Increase severity to warning in 2024.3, reject in 2025.3 - _LOGGER.debug( - "%s: set to unsupported color_mode: %s, supported_color_modes: %s", - self.entity_id, - color_mode, - supported_color_modes, - ) + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) set to unsupported color mode %s, expected one of %s, " + "this will stop working in Home Assistant Core 2025.3, " + "please %s" + ), + self.entity_id, + type(self), + color_mode, + supported_color_modes, + report_issue, + ) return # When an effect is active, the color mode should indicate what adjustments are @@ -1097,15 +1129,50 @@ def __validate_color_mode( if color_mode in effect_color_modes: return - # Increase severity to warning in 2024.3, reject in 2025.3 - _LOGGER.debug( - "%s: set to unsupported color_mode: %s, supported for effect: %s", - self.entity_id, - color_mode, - effect_color_modes, - ) + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) set to unsupported color mode %s when rendering an effect," + " expected one of %s, this will stop working in Home Assistant " + "Core 2025.3, please %s" + ), + self.entity_id, + type(self), + color_mode, + effect_color_modes, + report_issue, + ) return + def __validate_supported_color_modes( + self, + supported_color_modes: set[ColorMode] | set[str], + ) -> None: + """Validate the supported color modes.""" + if self.__color_mode_reported: + return + + try: + valid_supported_color_modes(supported_color_modes) + except vol.Error: + # Warning added in 2024.3, reject in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) sets invalid supported color modes %s, this will stop " + "working in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + supported_color_modes, + report_issue, + ) + @final @property def state_attributes(self) -> dict[str, Any] | None: @@ -1137,7 +1204,7 @@ def state_attributes(self) -> dict[str, Any] | None: data[ATTR_BRIGHTNESS] = None elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states - # Add warning in 2024.3, remove in 2025.3 + # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: data[ATTR_BRIGHTNESS] = self.brightness else: @@ -1158,7 +1225,7 @@ def state_attributes(self) -> dict[str, Any] | None: data[ATTR_COLOR_TEMP] = None elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility - # Add warning in 2024.3, remove in 2025.3 + # Warning is printed by supported_features_compat, remove in 2025.1 if _is_on: color_temp_kelvin = self.color_temp_kelvin data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin @@ -1191,10 +1258,23 @@ def state_attributes(self) -> dict[str, Any] | None: def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: """Calculate supported color modes with backwards compatibility.""" if (_supported_color_modes := self.supported_color_modes) is not None: + self.__validate_supported_color_modes(_supported_color_modes) return _supported_color_modes # Backwards compatibility for supported_color_modes added in 2021.4 - # Add warning in 2024.3, remove in 2025.3 + # Warning added in 2024.3, remove in 2025.3 + if not self.__color_mode_reported and self.__should_report_light_issue(): + self.__color_mode_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "%s (%s) does not set supported color modes, this will stop working" + " in Home Assistant Core 2025.3, please %s" + ), + self.entity_id, + type(self), + report_issue, + ) supported_features = self.supported_features_compat supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() @@ -1251,3 +1331,10 @@ def supported_features_compat(self) -> LightEntityFeature: report_issue, ) return new_features + + def __should_report_light_issue(self) -> bool: + """Return if light color mode issues should be reported.""" + if not self.platform: + return True + # philips_js and tuya have known issues, we don't need users to open issues + return self.platform.platform_name not in {"philips_js", "tuya"} diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 2a90e3e9e19945..213ee37ef374ee 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -73,7 +73,7 @@ def __init__( self._store = store self._calendar = calendar self._event: CalendarEvent | None = None - self._attr_name = name.capitalize() + self._attr_name = name self._attr_unique_id = unique_id @property diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index f5a24e07b0cd1a..53fd61a2924c21 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==6.1.1"] + "requirements": ["ical==7.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index 335a89eab3c4c2..b45eec12e62d9b 100644 --- a/homeassistant/components/local_todo/manifest.json +++ b/homeassistant/components/local_todo/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_todo", "iot_class": "local_polling", - "requirements": ["ical==6.1.1"] + "requirements": ["ical==7.0.0"] } diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index e94206317d7d11..292f8237776686 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,5 +1,6 @@ """A Local To-do todo platform.""" +import datetime import logging from ical.calendar import Calendar @@ -24,7 +25,8 @@ _LOGGER = logging.getLogger(__name__) -PRODID = "-//homeassistant.io//local_todo 1.0//EN" +PRODID = "-//homeassistant.io//local_todo 2.0//EN" +PRODID_REQUIRES_MIGRATION = "-//homeassistant.io//local_todo 1.0//EN" ICS_TODO_STATUS_MAP = { TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION, @@ -38,6 +40,25 @@ } +def _migrate_calendar(calendar: Calendar) -> bool: + """Upgrade due dates to rfc5545 format. + + In rfc5545 due dates are exclusive, however we previously set the due date + as inclusive based on what the user set in the UI. A task is considered + overdue at midnight at the start of a date so we need to shift the due date + to the next day for old calendar versions. + """ + if calendar.prodid is None or calendar.prodid != PRODID_REQUIRES_MIGRATION: + return False + migrated = False + for todo in calendar.todos: + if todo.due is None or isinstance(todo.due, datetime.datetime): + continue + todo.due += datetime.timedelta(days=1) + migrated = True + return migrated + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -48,12 +69,16 @@ async def async_setup_entry( store = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() calendar = IcsCalendarStream.calendar_from_ics(ics) + migrated = _migrate_calendar(calendar) calendar.prodid = PRODID name = config_entry.data[CONF_TODO_LIST_NAME] entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) async_add_entities([entity], True) + if migrated: + await entity.async_save() + def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" @@ -65,6 +90,8 @@ def _convert_item(item: TodoItem) -> Todo: if item.status: todo.status = ICS_TODO_STATUS_MAP_INV[item.status] todo.due = item.due + if todo.due and not isinstance(todo.due, datetime.datetime): + todo.due += datetime.timedelta(days=1) todo.description = item.description return todo @@ -99,31 +126,36 @@ def __init__( async def async_update(self) -> None: """Update entity state based on the local To-do items.""" - self._attr_todo_items = [ - TodoItem( - uid=item.uid, - summary=item.summary or "", - status=ICS_TODO_STATUS_MAP.get( - item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION - ), - due=item.due, - description=item.description, + todo_items = [] + for item in self._calendar.todos: + if (due := item.due) and not isinstance(due, datetime.datetime): + due -= datetime.timedelta(days=1) + todo_items.append( + TodoItem( + uid=item.uid, + summary=item.summary or "", + status=ICS_TODO_STATUS_MAP.get( + item.status or TodoStatus.NEEDS_ACTION, + TodoItemStatus.NEEDS_ACTION, + ), + due=due, + description=item.description, + ) ) - for item in self._calendar.todos - ] + self._attr_todo_items = todo_items async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" todo = _convert_item(item) TodoStore(self._calendar).add(todo) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" todo = _convert_item(item) TodoStore(self._calendar).edit(todo.uid, todo) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_delete_todo_items(self, uids: list[str]) -> None: @@ -131,7 +163,7 @@ async def async_delete_todo_items(self, uids: list[str]) -> None: store = TodoStore(self._calendar) for uid in uids: store.delete(uid) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) async def async_move_todo_item( @@ -156,10 +188,10 @@ async def async_move_todo_item( if dst_idx > src_idx: dst_idx -= 1 todos.insert(dst_idx, src_item) - await self._async_save() + await self.async_save() await self.async_update_ha_state(force_refresh=True) - async def _async_save(self) -> None: + async def async_save(self) -> None: """Persist the todo list to disk.""" content = IcsCalendarStream.calendar_to_ics(self._calendar) await self._store.async_store(content) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 0c614972e1e39c..891d1fb3fb055d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -145,9 +145,8 @@ def log_message(service: ServiceCall) -> None: return True -async def _process_logbook_platform( - hass: HomeAssistant, domain: str, platform: Any -) -> None: +@callback +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 diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 839a742224f485..b7293087e7e0d0 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -1,7 +1,7 @@ """Event parser and human readable log generator.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Any from homeassistant.components.sensor import ATTR_STATE_CLASS @@ -96,7 +96,7 @@ def async_determine_event_types( @callback -def extract_attr(source: dict[str, Any], attr: str) -> list[str]: +def extract_attr(source: Mapping[str, Any], attr: str) -> list[str]: """Extract an attribute as a list or string.""" if (value := source.get(attr)) is None: return [] diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 84ae84a3b706ba..22420f243c639e 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -1,7 +1,7 @@ """Event parser and human readable log generator.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast @@ -103,7 +103,7 @@ def context_parent_id(self) -> str | None: class EventAsRow: """Convert an event to a row.""" - data: dict[str, Any] + data: Mapping[str, Any] context: Context context_id_bin: bytes time_fired_ts: float diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 29d89a4c22faef..af41374ec9b7d5 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -9,7 +9,6 @@ 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 from .all import all_stmt from .devices import devices_stmt @@ -28,8 +27,8 @@ def statement_for_request( context_id: str | None = None, ) -> StatementLambdaElement: """Generate the logbook statement for a logbook request.""" - start_day = dt_util.utc_to_timestamp(start_day_dt) - end_day = dt_util.utc_to_timestamp(end_day_dt) + start_day = start_day_dt.timestamp() + end_day = end_day_dt.timestamp() # 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: diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 82124247adfd5b..0b1b34ca375c41 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -184,8 +184,8 @@ def _generate_stream_message( """Generate a logbook stream message response.""" return { "events": events, - "start_time": dt_util.utc_to_timestamp(start_day), - "end_time": dt_util.utc_to_timestamp(end_day), + "start_time": start_day.timestamp(), + "end_time": end_day.timestamp(), } diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index a14cd60c993751..fa358d05fcd038 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -22,7 +22,7 @@ Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -131,6 +131,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Logi Circle from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + breaks_in_ha_version="2024.9.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/logi_circle", + }, + ) + logi_circle = LogiCircle( client_id=entry.data[CONF_CLIENT_ID], client_secret=entry.data[CONF_CLIENT_SECRET], @@ -170,7 +183,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: notification_id=NOTIFICATION_ID, ) return False - except asyncio.TimeoutError: + except TimeoutError: # The TimeoutError exception object returns nothing when casted to a # string, so we'll handle it separately. err = f"{_TIMEOUT}s timeout exceeded when connecting to Logi Circle API" @@ -239,6 +252,13 @@ async def shut_down(event=None): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if all( + config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) logi_circle = hass.data.pop(DATA_LOGI) diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py index 9785940aca243d..be22a9a5d30b44 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -162,7 +162,7 @@ async def _async_create_session(self, code): except AuthorizationFailed: (self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS]) = "invalid_auth" return self.async_abort(reason="external_error") - except asyncio.TimeoutError: + except TimeoutError: ( self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS] ) = "authorize_url_timeout" diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json index 188139e6c293de..be0f4632c25aae 100644 --- a/homeassistant/components/logi_circle/strings.json +++ b/homeassistant/components/logi_circle/strings.json @@ -44,6 +44,12 @@ } } }, + "issues": { + "integration_removed": { + "title": "The Logi Circle integration has been deprecated and will be removed", + "description": "Logitech stopped accepting applications for access to the Logi Circle API in May 2022, and the Logi Circle integration will be removed from Home Assistant.\n\nTo resolve this issue, please remove the integration entries from your Home Assistant setup. [Click here to see your existing Logi Circle integration entries]({entries})." + } + }, "services": { "set_config": { "name": "Set config", diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 37156e9ca08baa..358ccc5ae37df9 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -101,7 +101,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: lookin_device = await lookin_protocol.get_info() devices = await lookin_protocol.get_devices() - except (asyncio.TimeoutError, aiohttp.ClientError, NoUsableService) as ex: + except (TimeoutError, aiohttp.ClientError, NoUsableService) as ex: raise ConfigEntryNotReady from ex if entry.unique_id != (found_uuid := lookin_device.id.upper()): diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index cc43baab1c812f..e22987ba426c60 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,7 +1,6 @@ """The loqed integration.""" from __future__ import annotations -import asyncio import logging import re @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), ) except ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, ) as ex: raise ConfigEntryNotReady(f"Unable to connect to bridge at {host}") from ex diff --git a/homeassistant/components/lupusec/config_flow.py b/homeassistant/components/lupusec/config_flow.py index aad57897c91ef1..1fae687cbdb513 100644 --- a/homeassistant/components/lupusec/config_flow.py +++ b/homeassistant/components/lupusec/config_flow.py @@ -106,9 +106,9 @@ async def test_host_connection( try: await hass.async_add_executor_job(lupupy.Lupusec, username, password, host) - except lupupy.LupusecException: + except lupupy.LupusecException as ex: _LOGGER.error("Failed to connect to Lupusec device at %s", host) - raise CannotConnect + raise CannotConnect from ex class CannotConnect(HomeAssistantError): diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 33cf6f21d6fe12..0dceada821ebc4 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -171,7 +171,7 @@ async def async_setup_entry( return False timed_out = True - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() timed_out = False diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 9b243a3ec98a11..21f7cbd9683c7c 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -117,7 +117,7 @@ async def async_step_link(self, user_input=None): assets = None try: assets = await async_pair(self.data[CONF_HOST]) - except (asyncio.TimeoutError, OSError): + except (TimeoutError, OSError): errors["base"] = "cannot_connect" if not errors: @@ -227,7 +227,7 @@ async def async_get_lutron_id(self) -> str | None: try: async with asyncio.timeout(BRIDGE_TIMEOUT): await bridge.connect() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "Timeout while trying to connect to bridge at %s", self.data[CONF_HOST], diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index af06bf0e0f0d2b..7493878beceaf9 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -14,6 +14,9 @@ BRIDGE_DEVICE_ID = "1" +DEVICE_TYPE_WHITE_TUNE = "WhiteTune" +DEVICE_TYPE_SPECTRUM_TUNE = "SpectrumTune" + MANUFACTURER = "Lutron Electronics Co., Inc" ATTR_SERIAL = "serial" diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index ffab06896366c3..eb3e38b2e397f1 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -2,9 +2,18 @@ from datetime import timedelta from typing import Any +from pylutron_caseta.color_value import ( + ColorMode as LutronColorMode, + FullColorValue, + WarmCoolColorValue, +) + from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, ATTR_TRANSITION, + ATTR_WHITE, DOMAIN, ColorMode, LightEntity, @@ -15,9 +24,24 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import DOMAIN as CASETA_DOMAIN +from .const import ( + DEVICE_TYPE_SPECTRUM_TUNE, + DEVICE_TYPE_WHITE_TUNE, + DOMAIN as CASETA_DOMAIN, +) from .models import LutronCasetaData +SUPPORTED_COLOR_MODE_DICT = { + DEVICE_TYPE_SPECTRUM_TUNE: { + ColorMode.HS, + ColorMode.COLOR_TEMP, + ColorMode.WHITE, + }, + DEVICE_TYPE_WHITE_TUNE: {ColorMode.COLOR_TEMP}, +} + +WARM_DEVICE_TYPES = {DEVICE_TYPE_WHITE_TUNE, DEVICE_TYPE_SPECTRUM_TUNE} + def to_lutron_level(level): """Convert the given Home Assistant light level (0-255) to Lutron (0-100).""" @@ -48,37 +72,158 @@ async def async_setup_entry( class LutronCasetaLight(LutronCasetaDeviceUpdatableEntity, LightEntity): - """Representation of a Lutron Light, including dimmable.""" + """Representation of a Lutron Light, including dimmable, white tune, and spectrum tune.""" - _attr_color_mode = ColorMode.BRIGHTNESS - _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_features = LightEntityFeature.TRANSITION + def __init__(self, light: dict[str, Any], data: LutronCasetaData) -> None: + """Initialize the light and set the supported color modes. + + :param light: The lutron light device to initialize. + :param data: The integration data + """ + super().__init__(light, data) + + self._attr_min_color_temp_kelvin = self._get_min_color_temp_kelvin(light) + self._attr_max_color_temp_kelvin = self._get_max_color_temp_kelvin(light) + + light_type = light["type"] + self._attr_supported_color_modes = SUPPORTED_COLOR_MODE_DICT.get( + light_type, {ColorMode.BRIGHTNESS} + ) + + self.supports_warm_cool = light_type in WARM_DEVICE_TYPES + self.supports_warm_dim = light_type == DEVICE_TYPE_SPECTRUM_TUNE + self.supports_spectrum_tune = light_type == DEVICE_TYPE_SPECTRUM_TUNE + + def _get_min_color_temp_kelvin(self, light: dict[str, Any]) -> int: + """Return minimum supported color temperature. + + :param light: The light to get the minimum color temperature for. + """ + white_tune_range = light.get("white_tuning_range") + # Default to 1.4k if not found + if white_tune_range is None or "Min" not in white_tune_range: + return 1400 + + return white_tune_range.get("Min") + + def _get_max_color_temp_kelvin(self, light: dict[str, Any]) -> int: + """Return maximum supported color temperature. + + :param light: The light to get the maximum color temperature for. + """ + white_tune_range = light.get("white_tuning_range") + # Default to 10k if not found + if white_tune_range is None or "Max" not in white_tune_range: + return 10000 + + return white_tune_range.get("Max") + @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" return to_hass_level(self._device["current_state"]) - async def _set_brightness(self, brightness, **kwargs): + async def _async_set_brightness( + self, brightness: int | None, color_value: LutronColorMode | None, **kwargs: Any + ) -> None: args = {} if ATTR_TRANSITION in kwargs: args["fade_time"] = timedelta(seconds=kwargs[ATTR_TRANSITION]) + if brightness is not None: + brightness = to_lutron_level(brightness) await self._smartbridge.set_value( - self.device_id, to_lutron_level(brightness), **args + self.device_id, value=brightness, color_value=color_value, **args + ) + + async def _async_set_warm_dim(self, brightness: int | None, **kwargs: Any): + """Set the light to warm dim mode.""" + set_warm_dim_kwargs: dict[str, Any] = {} + if ATTR_TRANSITION in kwargs: + set_warm_dim_kwargs["fade_time"] = timedelta( + seconds=kwargs[ATTR_TRANSITION] + ) + + if brightness is not None: + brightness = to_lutron_level(brightness) + + await self._smartbridge.set_warm_dim( + self.device_id, brightness, **set_warm_dim_kwargs ) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - brightness = kwargs.pop(ATTR_BRIGHTNESS, 255) + # first check for "white mode" (WarmDim) + if (white_color := kwargs.get(ATTR_WHITE)) is not None: + await self._async_set_warm_dim(white_color) + return + + brightness = kwargs.pop(ATTR_BRIGHTNESS, None) + color: LutronColorMode | None = None + hs_color: tuple[float, float] | None = kwargs.pop(ATTR_HS_COLOR, None) + kelvin_color: int | None = kwargs.pop(ATTR_COLOR_TEMP_KELVIN, None) + + if hs_color is not None: + color = FullColorValue(hs_color[0], hs_color[1]) + elif kelvin_color is not None: + color = WarmCoolColorValue(kelvin_color) - await self._set_brightness(brightness, **kwargs) + # if user is pressing on button nothing is set, so set brightness to 255 + if color is None and brightness is None: + brightness = 255 + + await self._async_set_brightness(brightness, color, **kwargs) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" - await self._set_brightness(0, **kwargs) + await self._async_set_brightness(0, None, **kwargs) + + @property + def color_mode(self) -> ColorMode: + """Return the current color mode of the light.""" + + currently_warm_dim = self._device.get("warm_dim", False) + if self.supports_warm_dim and currently_warm_dim: + return ColorMode.WHITE + + current_color = self._device.get("color") + if self.supports_warm_cool and isinstance(current_color, WarmCoolColorValue): + return ColorMode.COLOR_TEMP + + if self.supports_spectrum_tune and isinstance(current_color, FullColorValue): + return ColorMode.HS + + return ColorMode.BRIGHTNESS @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device["current_state"] > 0 + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the current color of the light.""" + current_color: FullColorValue | WarmCoolColorValue | None = self._device.get( + "color" + ) + + # if bulb is set to full spectrum, return the hue and saturation + if isinstance(current_color, FullColorValue): + return (current_color.hue, current_color.saturation) + + return None + + @property + def color_temp_kelvin(self) -> int | None: + """Return the CT color value in kelvin.""" + current_color: FullColorValue | WarmCoolColorValue | None = self._device.get( + "color" + ) + + # if bulb is set to warm cool mode, return the kelvin value + if isinstance(current_color, WarmCoolColorValue): + return current_color.kelvin + + return None diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index e549e37d59d985..48445f645aa74e 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -1,7 +1,7 @@ { "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", - "codeowners": ["@swails", "@bdraco", "@danaues"], + "codeowners": ["@swails", "@bdraco", "@danaues", "@eclair4151"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", "homekit": { @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.19.0"], + "requirements": ["pylutron-caseta==0.20.0"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/homeassistant/components/madeco/__init__.py b/homeassistant/components/madeco/__init__.py new file mode 100644 index 00000000000000..c766e21cf7e741 --- /dev/null +++ b/homeassistant/components/madeco/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Madeco.""" diff --git a/homeassistant/components/madeco/manifest.json b/homeassistant/components/madeco/manifest.json new file mode 100644 index 00000000000000..22f5f705870c50 --- /dev/null +++ b/homeassistant/components/madeco/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "madeco", + "name": "Madeco", + "integration_type": "virtual", + "supported_by": "motion_blinds" +} diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 623d0f062953e9..1becc15624e50c 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform @@ -61,6 +62,23 @@ async def async_setup_platform( _LOGGER.error("Unknown mailbox platform specified") return + if p_type not in ["asterisk_cdr", "asterisk_mbox", "demo"]: + # Asterisk integration will raise a repair issue themselves + # For demo we don't create one + async_create_issue( + hass, + DOMAIN, + f"deprecated_mailbox_{p_type}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_mailbox_integration", + translation_placeholders={ + "integration_domain": p_type, + }, + ) + _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) mailbox = None try: @@ -262,7 +280,7 @@ async def get( """Retrieve media.""" mailbox = self.get_mailbox(platform) - with suppress(asyncio.CancelledError, asyncio.TimeoutError): + with suppress(asyncio.CancelledError, TimeoutError): async with asyncio.timeout(10): try: stream = await mailbox.async_get_media(msgid) diff --git a/homeassistant/components/mailbox/strings.json b/homeassistant/components/mailbox/strings.json index 84acd440044507..44f1ad08d397ec 100644 --- a/homeassistant/components/mailbox/strings.json +++ b/homeassistant/components/mailbox/strings.json @@ -1 +1,9 @@ -{ "title": "Mailbox" } +{ + "title": "Mailbox", + "issues": { + "deprecated_mailbox": { + "title": "The mailbox platform is being removed", + "description": "The mailbox platform is being removed. Please report it to the author of the '{integration_domain}' custom integration." + } + } +} diff --git a/homeassistant/components/martec/__init__.py b/homeassistant/components/martec/__init__.py new file mode 100644 index 00000000000000..76383e3b71983d --- /dev/null +++ b/homeassistant/components/martec/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Martec.""" diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index 3a82e4668885e2..06c205859bbd18 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -1,4 +1,5 @@ """The Matter integration.""" + from __future__ import annotations import asyncio @@ -45,7 +46,10 @@ def get_matter_device_info( hass: HomeAssistant, device_id: str ) -> MatterDeviceInfo | None: """Return Matter device info or None if device does not exist.""" - if not (node := node_from_ha_device_id(hass, device_id)): + # Test hass.data[DOMAIN] to ensure config entry is set up + if not hass.data.get(DOMAIN, False) or not ( + node := node_from_ha_device_id(hass, device_id) + ): return None return MatterDeviceInfo( @@ -64,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(CONNECT_TIMEOUT): await matter_client.connect() - except (CannotConnect, asyncio.TimeoutError) as err: + except (CannotConnect, TimeoutError) as err: raise ConfigEntryNotReady("Failed to connect to matter server") from err except InvalidServerVersion as err: if use_addon: @@ -109,7 +113,7 @@ async def on_hass_stop(event: Event) -> None: try: async with asyncio.timeout(LISTEN_READY_TIMEOUT): await init_ready.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: listen_task.cancel() raise ConfigEntryNotReady("Matter client not ready") from err diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 7e6f42f44b4fed..aa93cef9916ddb 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -10,10 +10,12 @@ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_TRANSITION, ATTR_XY_COLOR, ColorMode, LightEntity, LightEntityDescription, + LightEntityFeature, filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry @@ -38,6 +40,7 @@ clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, } +DEFAULT_TRANSITION = 0.2 async def async_setup_entry( @@ -58,7 +61,9 @@ class MatterLight(MatterEntity, LightEntity): _supports_color = False _supports_color_temperature = False - async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: + async def _set_xy_color( + self, xy_color: tuple[float, float], transition: float = 0.0 + ) -> None: """Set xy color.""" matter_xy = convert_to_matter_xy(xy_color) @@ -67,8 +72,8 @@ async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: clusters.ColorControl.Commands.MoveToColor( colorX=int(matter_xy[0]), colorY=int(matter_xy[1]), - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -76,7 +81,9 @@ async def _set_xy_color(self, xy_color: tuple[float, float]) -> None: ) ) - async def _set_hs_color(self, hs_color: tuple[float, float]) -> None: + async def _set_hs_color( + self, hs_color: tuple[float, float], transition: float = 0.0 + ) -> None: """Set hs color.""" matter_hs = convert_to_matter_hs(hs_color) @@ -85,8 +92,8 @@ async def _set_hs_color(self, hs_color: tuple[float, float]) -> None: clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=int(matter_hs[0]), saturation=int(matter_hs[1]), - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -94,14 +101,14 @@ async def _set_hs_color(self, hs_color: tuple[float, float]) -> None: ) ) - async def _set_color_temp(self, color_temp: int) -> None: + async def _set_color_temp(self, color_temp: int, transition: float = 0.0) -> None: """Set color temperature.""" await self.send_device_command( clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperatureMireds=color_temp, - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), # allow setting the color while the light is off, # by setting the optionsMask to 1 (=ExecuteIfOff) optionsMask=1, @@ -109,7 +116,7 @@ async def _set_color_temp(self, color_temp: int) -> None: ) ) - async def _set_brightness(self, brightness: int) -> None: + async def _set_brightness(self, brightness: int, transition: float = 0.0) -> None: """Set brightness.""" level_control = self._endpoint.get_cluster(clusters.LevelControl) @@ -127,8 +134,8 @@ async def _set_brightness(self, brightness: int) -> None: await self.send_device_command( clusters.LevelControl.Commands.MoveToLevelWithOnOff( level=level, - # It's required in TLV. We don't implement transition time yet. - transitionTime=0, + # transition in matter is measured in tenths of a second + transitionTime=int(transition * 10), ) ) @@ -251,20 +258,21 @@ async def async_turn_on(self, **kwargs: Any) -> None: xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) + transition = kwargs.get(ATTR_TRANSITION, DEFAULT_TRANSITION) if self.supported_color_modes is not None: if hs_color is not None and ColorMode.HS in self.supported_color_modes: - await self._set_hs_color(hs_color) + await self._set_hs_color(hs_color, transition) elif xy_color is not None and ColorMode.XY in self.supported_color_modes: - await self._set_xy_color(xy_color) + await self._set_xy_color(xy_color, transition) elif ( color_temp is not None and ColorMode.COLOR_TEMP in self.supported_color_modes ): - await self._set_color_temp(color_temp) + await self._set_color_temp(color_temp, transition) if brightness is not None and self._supports_brightness: - await self._set_brightness(brightness) + await self._set_brightness(brightness, transition) return await self.send_device_command( @@ -324,6 +332,9 @@ def _update_from_device(self) -> None: supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes + # flag support for transition as soon as we support setting brightness and/or color + if supported_color_modes != {ColorMode.ONOFF}: + self._attr_supported_features |= LightEntityFeature.TRANSITION LOGGER.debug( "Supported color modes: %s for %s", diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 801704c25c5a19..0e1ed4e80b6b98 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", + "import_executor": true, "iot_class": "local_push", - "requirements": ["python-matter-server==5.5.0"] + "requirements": ["python-matter-server==5.7.0"] } diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index f8899ea082f6fa..41aed4be15c4d9 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -1,6 +1,5 @@ """Support for the MAX! Cube LAN Gateway.""" import logging -from socket import timeout from threading import Lock import time @@ -65,7 +64,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: cube = MaxCube(host, port, now=now) hass.data[DATA_KEY][host] = MaxCubeHandle(cube, scan_interval) - except timeout as ex: + except TimeoutError as ex: _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) persistent_notification.create( hass, @@ -108,7 +107,7 @@ def update(self): try: self.cube.update() - except timeout: + except TimeoutError: _LOGGER.error("Max!Cube connection failed") return False diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index f3d302fc209ad2..42abed48724b74 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import socket from typing import Any from maxcube.device import ( @@ -152,7 +151,7 @@ def _set_target(self, mode: int | None, temp: float | None) -> None: with self._cubehandle.mutex: try: self._cubehandle.cube.set_temperature_mode(self._device, temp, mode) - except (socket.timeout, OSError): + except (TimeoutError, OSError): _LOGGER.error("Setting HVAC mode failed") @property diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 12fdb7f3a0691f..ee2307fbc849d7 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_data() -> dict[str, MeaterProbe]: """Fetch data from API endpoint.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): devices: list[MeaterProbe] = await meater_api.get_all_devices() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 673f0a44374cb6..ffb1d6d4a322da 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1345,7 +1345,7 @@ async def async_fetch_image( """Retrieve an image.""" content, content_type = (None, None) websession = async_get_clientsession(hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): response = await websession.get(url) if response.status == HTTPStatus.OK: diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py new file mode 100644 index 00000000000000..b0c0e7f559ec69 --- /dev/null +++ b/homeassistant/components/media_player/intent.py @@ -0,0 +1,50 @@ +"""Intents for the media_player integration.""" + +import voluptuous as vol + +from homeassistant.const import ( + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_VOLUME_SET, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import ATTR_MEDIA_VOLUME_LEVEL, DOMAIN + +INTENT_MEDIA_PAUSE = "HassMediaPause" +INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" +INTENT_MEDIA_NEXT = "HassMediaNext" +INTENT_SET_VOLUME = "HassSetVolume" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the media_player intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler(INTENT_MEDIA_UNPAUSE, DOMAIN, SERVICE_MEDIA_PLAY), + ) + intent.async_register( + hass, + intent.ServiceIntentHandler(INTENT_MEDIA_PAUSE, DOMAIN, SERVICE_MEDIA_PAUSE), + ) + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_MEDIA_NEXT, DOMAIN, SERVICE_MEDIA_NEXT_TRACK + ), + ) + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_SET_VOLUME, + DOMAIN, + SERVICE_VOLUME_SET, + extra_slots={ + ATTR_MEDIA_VOLUME_LEVEL: vol.All( + vol.Range(min=0, max=100), lambda val: val / 100 + ) + }, + ), + ) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 62cf78156130ae..fdb7fa5f1f2cea 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -148,7 +148,10 @@ async def async_resolve_media( raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: - report("calls media_source.async_resolve_media without passing an entity_id") + report( + "calls media_source.async_resolve_media without passing an entity_id", + {DOMAIN}, + ) target_media_player = None try: diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 2fa7e87d73760a..2db3e79dfe906a 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if isinstance(ex, ClientResponseError) and ex.code == 401: raise ConfigEntryAuthFailed from ex raise ConfigEntryNotReady from ex - except (asyncio.TimeoutError, ClientConnectionError) as ex: + except (TimeoutError, ClientConnectionError) as ex: raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 9db44d5276c6f4..88f658a06155ba 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,7 +60,7 @@ async def _create_client( if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): return self.async_abort(reason="invalid_auth") return self.async_abort(reason="cannot_connect") - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): return self.async_abort(reason="cannot_connect") return await self._create_entry(username, acquired_token) @@ -126,17 +126,21 @@ async def async_reauthenticate_client( async_get_clientsession(self.hass), ) except (ClientResponseError, AttributeError) as err: - if isinstance(err, ClientResponseError) and err.status in ( - HTTPStatus.UNAUTHORIZED, - HTTPStatus.FORBIDDEN, + if ( + isinstance(err, ClientResponseError) + and err.status + in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ) + or isinstance(err, AttributeError) + and err.name == "get" ): errors["base"] = "invalid_auth" - elif isinstance(err, AttributeError) and err.name == "get": - errors["base"] = "invalid_auth" else: errors["base"] = "cannot_connect" except ( - asyncio.TimeoutError, + TimeoutError, ClientError, ): errors["base"] = "cannot_connect" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 11b044311d25c7..e4a63e326a6a48 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -68,16 +68,15 @@ async def async_setup_entry( if TYPE_CHECKING: assert isinstance(name, str) - entities = [MetWeather(coordinator, config_entry, False, name, is_metric)] + entities = [MetWeather(coordinator, config_entry, name, is_metric)] - # Add hourly entity to legacy config entries - if entity_registry.async_get_entity_id( + # Remove hourly entity from legacy config entries + if hourly_entity_id := entity_registry.async_get_entity_id( WEATHER_DOMAIN, DOMAIN, _calculate_unique_id(config_entry.data, True), ): - name = f"{name} hourly" - entities.append(MetWeather(coordinator, config_entry, True, name, is_metric)) + entity_registry.async_remove(hourly_entity_id) async_add_entities(entities) @@ -121,17 +120,14 @@ def __init__( self, coordinator: MetDataUpdateCoordinator, config_entry: ConfigEntry, - hourly: bool, name: str, is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) - self._attr_unique_id = _calculate_unique_id(config_entry.data, hourly) + self._attr_unique_id = _calculate_unique_id(config_entry.data, False) self._config = config_entry.data self._is_metric = is_metric - self._hourly = hourly - self._attr_entity_registry_enabled_default = not hourly self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, @@ -237,7 +233,7 @@ def _forecast(self, hourly: bool) -> list[Forecast] | None: @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - return self._forecast(self._hourly) + return self._forecast(False) @callback def _async_forecast_daily(self) -> list[Forecast] | None: diff --git a/homeassistant/components/microbees/__init__.py b/homeassistant/components/microbees/__init__.py new file mode 100644 index 00000000000000..488988ab593f25 --- /dev/null +++ b/homeassistant/components/microbees/__init__.py @@ -0,0 +1,64 @@ +"""The microBees integration.""" + +from dataclasses import dataclass +from http import HTTPStatus + +import aiohttp +from microBeesPy import MicroBees + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, PLATFORMS +from .coordinator import MicroBeesUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class HomeAssistantMicroBeesData: + """Microbees data stored in the Home Assistant data object.""" + + connector: MicroBees + coordinator: MicroBeesUpdateCoordinator + session: config_entry_oauth2_flow.OAuth2Session + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up microBees from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as ex: + if ex.status in ( + HTTPStatus.BAD_REQUEST, + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + raise ConfigEntryNotReady from ex + microbees = MicroBees(token=session.token[CONF_ACCESS_TOKEN]) + coordinator = MicroBeesUpdateCoordinator(hass, microbees) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantMicroBeesData( + connector=microbees, + coordinator=coordinator, + session=session, + ) + 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/microbees/api.py b/homeassistant/components/microbees/api.py new file mode 100644 index 00000000000000..ec835169231618 --- /dev/null +++ b/homeassistant/components/microbees/api.py @@ -0,0 +1,28 @@ +"""API for microBees bound to Home Assistant OAuth.""" + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +class ConfigEntryAuth: + """Provide microBees authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize microBees Auth.""" + self.oauth_session = oauth2_session + self.hass = hass + + @property + def access_token(self) -> str: + """Return the access token.""" + return self.oauth_session.token[CONF_ACCESS_TOKEN] + + async def check_and_refresh_token(self) -> str: + """Check the token.""" + await self.oauth_session.async_ensure_token_valid() + return self.access_token diff --git a/homeassistant/components/microbees/application_credentials.py b/homeassistant/components/microbees/application_credentials.py new file mode 100644 index 00000000000000..89b591c0f4122b --- /dev/null +++ b/homeassistant/components/microbees/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the microBees integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return auth implementation.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/microbees/button.py b/homeassistant/components/microbees/button.py new file mode 100644 index 00000000000000..cf82a60bfa436c --- /dev/null +++ b/homeassistant/components/microbees/button.py @@ -0,0 +1,53 @@ +"""Button integration microBees.""" +from typing import Any + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator +from .entity import MicroBeesActuatorEntity + +BUTTON_TRANSLATIONS = {51: "button_gate", 91: "button_panic"} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the microBees button platform.""" + coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ].coordinator + async_add_entities( + MBButton(coordinator, bee_id, button.id) + for bee_id, bee in coordinator.data.bees.items() + if bee.productID in BUTTON_TRANSLATIONS + for button in bee.actuators + ) + + +class MBButton(MicroBeesActuatorEntity, ButtonEntity): + """Representation of a microBees button.""" + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + actuator_id: int, + ) -> None: + """Initialize the microBees button.""" + super().__init__(coordinator, bee_id, actuator_id) + self._attr_translation_key = BUTTON_TRANSLATIONS.get(self.bee.productID) + + @property + def name(self) -> str: + """Name of the switch.""" + return self.actuator.name + + async def async_press(self, **kwargs: Any) -> None: + """Turn on the button.""" + await self.coordinator.microbees.sendCommand( + self.actuator.id, self.actuator.configuration.actuator_timing * 1000 + ) diff --git a/homeassistant/components/microbees/config_flow.py b/homeassistant/components/microbees/config_flow.py new file mode 100644 index 00000000000000..fb0b5faa020442 --- /dev/null +++ b/homeassistant/components/microbees/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for microBees integration.""" +from collections.abc import Mapping +import logging +from typing import Any + +from microBeesPy import MicroBees, MicroBeesException + +from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from .const import DOMAIN + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Handle a config flow for microBees.""" + + DOMAIN = DOMAIN + reauth_entry: config_entries.ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + scopes = ["read", "write"] + return {"scope": " ".join(scopes)} + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + + microbees = MicroBees( + session=aiohttp_client.async_get_clientsession(self.hass), + token=data[CONF_TOKEN][CONF_ACCESS_TOKEN], + ) + + try: + current_user = await microbees.getMyProfile() + except MicroBeesException: + return self.async_abort(reason="invalid_auth") + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected error") + return self.async_abort(reason="unknown") + + if not self.reauth_entry: + await self.async_set_unique_id(current_user.id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=current_user.username, + data=data, + ) + if self.reauth_entry.unique_id == current_user.id: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="wrong_account") + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/microbees/const.py b/homeassistant/components/microbees/const.py new file mode 100644 index 00000000000000..cf7644c8dfa84c --- /dev/null +++ b/homeassistant/components/microbees/const.py @@ -0,0 +1,12 @@ +"""Constants for the microBees integration.""" +from homeassistant.const import Platform + +DOMAIN = "microbees" +OAUTH2_AUTHORIZE = "https://dev.microbees.com/oauth/authorize" +OAUTH2_TOKEN = "https://dev.microbees.com/oauth/token" +PLATFORMS = [ + Platform.BUTTON, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] diff --git a/homeassistant/components/microbees/coordinator.py b/homeassistant/components/microbees/coordinator.py new file mode 100644 index 00000000000000..af207507e775c3 --- /dev/null +++ b/homeassistant/components/microbees/coordinator.py @@ -0,0 +1,67 @@ +"""The microBees Coordinator.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta +from http import HTTPStatus +import logging + +import aiohttp +from microBeesPy import Actuator, Bee, MicroBees, MicroBeesException, Sensor + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MicroBeesCoordinatorData: + """Microbees data from the Coordinator.""" + + bees: dict[int, Bee] + actuators: dict[int, Actuator] + sensors: dict[int, Sensor] + + +class MicroBeesUpdateCoordinator(DataUpdateCoordinator[MicroBeesCoordinatorData]): + """MicroBees coordinator.""" + + def __init__(self, hass: HomeAssistant, microbees: MicroBees) -> None: + """Initialize microBees coordinator.""" + super().__init__( + hass, + _LOGGER, + name="microBees Coordinator", + update_interval=timedelta(seconds=30), + ) + self.microbees = microbees + + async def _async_update_data(self) -> MicroBeesCoordinatorData: + """Fetch data from API endpoint.""" + async with asyncio.timeout(10): + try: + bees = await self.microbees.getBees() + except aiohttp.ClientResponseError as err: + if err.status is HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed( + "Token not valid, trigger renewal" + ) from err + raise UpdateFailed(f"Error communicating with API: {err}") from err + + except MicroBeesException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + bees_dict = {} + actuators_dict = {} + sensors_dict = {} + for bee in bees: + bees_dict[bee.id] = bee + for actuator in bee.actuators: + actuators_dict[actuator.id] = actuator + for sensor in bee.sensors: + sensors_dict[sensor.id] = sensor + return MicroBeesCoordinatorData( + bees=bees_dict, actuators=actuators_dict, sensors=sensors_dict + ) diff --git a/homeassistant/components/microbees/entity.py b/homeassistant/components/microbees/entity.py new file mode 100644 index 00000000000000..0efb2ec437b3cc --- /dev/null +++ b/homeassistant/components/microbees/entity.py @@ -0,0 +1,64 @@ +"""Base entity for microBees.""" + +from microBeesPy import Actuator, Bee + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator + + +class MicroBeesEntity(CoordinatorEntity[MicroBeesUpdateCoordinator]): + """Base class for microBees entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + ) -> None: + """Initialize the microBees entity.""" + super().__init__(coordinator) + self.bee_id = bee_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(bee_id))}, + manufacturer="microBees", + name=self.bee.name, + model=self.bee.prototypeName, + ) + + @property + def available(self) -> bool: + """Status of the bee.""" + return ( + super().available + and self.bee_id in self.coordinator.data.bees + and self.bee.active + ) + + @property + def bee(self) -> Bee: + """Return the bee.""" + return self.coordinator.data.bees[self.bee_id] + + +class MicroBeesActuatorEntity(MicroBeesEntity): + """Base class for microBees entities with actuator.""" + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + actuator_id: int, + ) -> None: + """Initialize the microBees entity.""" + super().__init__(coordinator, bee_id) + self.actuator_id = actuator_id + self._attr_unique_id = f"{bee_id}_{actuator_id}" + + @property + def actuator(self) -> Actuator: + """Return the actuator.""" + return self.coordinator.data.actuators[self.actuator_id] diff --git a/homeassistant/components/microbees/icons.json b/homeassistant/components/microbees/icons.json new file mode 100644 index 00000000000000..b4c997c2576bba --- /dev/null +++ b/homeassistant/components/microbees/icons.json @@ -0,0 +1,20 @@ +{ + "entity": { + "switch": { + "socket_eu": { + "default": "mdi:power-socket-eu" + }, + "socket_it": { + "default": "mdi:power-socket-it" + } + }, + "button": { + "button_gate": { + "default": "mdi:gate" + }, + "button_panic": { + "default": "mdi:alert-octagram" + } + } + } +} diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py new file mode 100644 index 00000000000000..7616cba41b0e9d --- /dev/null +++ b/homeassistant/components/microbees/light.py @@ -0,0 +1,77 @@ +"""Light integration microBees.""" +from typing import Any + +from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator +from .entity import MicroBeesActuatorEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Config entry.""" + coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ].coordinator + async_add_entities( + MBLight(coordinator, bee_id, light.id) + for bee_id, bee in coordinator.data.bees.items() + if bee.productID in (31, 79) + for light in bee.actuators + ) + + +class MBLight(MicroBeesActuatorEntity, LightEntity): + """Representation of a microBees light.""" + + _attr_supported_color_modes = {ColorMode.RGBW} + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + actuator_id: int, + ) -> None: + """Initialize the microBees light.""" + super().__init__(coordinator, bee_id, actuator_id) + self._attr_rgbw_color = self.actuator.configuration.color + + @property + def name(self) -> str: + """Name of the cover.""" + return self.actuator.name + + @property + def is_on(self) -> bool: + """Status of the light.""" + return self.actuator.value + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + if ATTR_RGBW_COLOR in kwargs: + self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR] + sendCommand = await self.coordinator.microbees.sendCommand( + self.actuator_id, 1, color=self._attr_rgbw_color + ) + if sendCommand: + self.actuator.value = True + self.async_write_ha_state() + else: + raise HomeAssistantError(f"Failed to turn on {self.name}") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + sendCommand = await self.coordinator.microbees.sendCommand( + self.actuator_id, 0, color=self._attr_rgbw_color + ) + if sendCommand: + self.actuator.value = False + self.async_write_ha_state() + else: + raise HomeAssistantError(f"Failed to turn off {self.name}") diff --git a/homeassistant/components/microbees/manifest.json b/homeassistant/components/microbees/manifest.json new file mode 100644 index 00000000000000..91b7d66d80fd79 --- /dev/null +++ b/homeassistant/components/microbees/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "microbees", + "name": "microBees", + "codeowners": ["@microBeesTech"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/microbees", + "iot_class": "cloud_polling", + "requirements": ["microBeesPy==0.3.2"] +} diff --git a/homeassistant/components/microbees/sensor.py b/homeassistant/components/microbees/sensor.py new file mode 100644 index 00000000000000..56db4c00ee36c3 --- /dev/null +++ b/homeassistant/components/microbees/sensor.py @@ -0,0 +1,107 @@ +"""sensor integration microBees.""" +from microBeesPy import Sensor + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + UnitOfPower, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator +from .entity import MicroBeesEntity + +SENSOR_TYPES = { + 0: SensorEntityDescription( + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + key="absorption", + suggested_display_precision=2, + ), + 2: SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + key="temperature", + suggested_display_precision=1, + ), + 14: SensorEntityDescription( + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="carbon_dioxide", + suggested_display_precision=1, + ), + 16: SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + key="humidity", + suggested_display_precision=1, + ), + 21: SensorEntityDescription( + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=LIGHT_LUX, + key="illuminance", + suggested_display_precision=1, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id].coordinator + + async_add_entities( + MBSensor(coordinator, desc, bee_id, sensor.id) + for bee_id, bee in coordinator.data.bees.items() + for sensor in bee.sensors + if (desc := SENSOR_TYPES.get(sensor.device_type)) is not None + ) + + +class MBSensor(MicroBeesEntity, SensorEntity): + """Representation of a microBees sensor.""" + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + entity_description: SensorEntityDescription, + bee_id: int, + sensor_id: int, + ) -> None: + """Initialize the microBees sensor.""" + super().__init__(coordinator, bee_id) + self._attr_unique_id = f"{bee_id}_{sensor_id}" + self.sensor_id = sensor_id + self.entity_description = entity_description + + @property + def name(self) -> str: + """Name of the sensor.""" + return self.sensor.name + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.sensor.value + + @property + def sensor(self) -> Sensor: + """Return the sensor.""" + return self.coordinator.data.sensors[self.sensor_id] diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json new file mode 100644 index 00000000000000..6f17a12834e079 --- /dev/null +++ b/homeassistant/components/microbees/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/microbees/switch.py b/homeassistant/components/microbees/switch.py new file mode 100644 index 00000000000000..4a52d95620b5c3 --- /dev/null +++ b/homeassistant/components/microbees/switch.py @@ -0,0 +1,71 @@ +"""Switch integration microBees.""" +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import MicroBeesUpdateCoordinator +from .entity import MicroBeesActuatorEntity + +SOCKET_TRANSLATIONS = {46: "socket_it", 38: "socket_eu"} +SWITCH_PRODUCT_IDS = {25, 26, 27, 35, 38, 46, 63, 64, 65, 86} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id].coordinator + + async_add_entities( + MBSwitch(coordinator, bee_id, switch.id) + for bee_id, bee in coordinator.data.bees.items() + if bee.productID in SWITCH_PRODUCT_IDS + for switch in bee.actuators + ) + + +class MBSwitch(MicroBeesActuatorEntity, SwitchEntity): + """Representation of a microBees switch.""" + + def __init__( + self, + coordinator: MicroBeesUpdateCoordinator, + bee_id: int, + actuator_id: int, + ) -> None: + """Initialize the microBees switch.""" + super().__init__(coordinator, bee_id, actuator_id) + self._attr_translation_key = SOCKET_TRANSLATIONS.get(self.bee.productID) + + @property + def name(self) -> str: + """Name of the switch.""" + return self.actuator.name + + @property + def is_on(self) -> bool: + """Status of the switch.""" + return self.actuator.value + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + send_command = await self.coordinator.microbees.sendCommand(self.actuator_id, 1) + if send_command: + self.actuator.value = True + self.async_write_ha_state() + else: + raise HomeAssistantError(f"Failed to turn on {self.name}") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + send_command = await self.coordinator.microbees.sendCommand(self.actuator_id, 0) + if send_command: + self.actuator.value = False + self.async_write_ha_state() + else: + raise HomeAssistantError(f"Failed to turn off {self.name}") diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index af0567f99a1e6c..e3f722ae2becd7 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -334,7 +334,7 @@ async def call_api(self, method, function, data=None, binary=False, params=None) except aiohttp.ClientError: _LOGGER.warning("Can't connect to microsoft face api") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from microsoft face api %s", response.url) raise HomeAssistantError("Network error on microsoft face api.") diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 14fbb83b61b9e0..8136334514fa1d 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -34,11 +34,10 @@ async def async_setup_entry( registry = er.async_get(hass) # Restore clients that is not a part of active clients list. - for entity in registry.entities.values(): - if ( - entity.config_entry_id == config_entry.entry_id - and entity.domain == DEVICE_TRACKER - ): + for entity in registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ): + if entity.domain == DEVICE_TRACKER: if ( entity.unique_id in coordinator.api.devices or entity.unique_id not in coordinator.api.all_devices diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 44d60d5dcb40d8..044a45fb9b5765 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -3,7 +3,6 @@ from datetime import timedelta import logging -import socket import ssl from typing import Any @@ -227,7 +226,7 @@ def command( except ( librouteros.exceptions.ConnectionClosed, OSError, - socket.timeout, + TimeoutError, ) as api_error: _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) # try to reconnect @@ -330,7 +329,7 @@ def get_api(entry: dict[str, Any]) -> librouteros.Api: except ( librouteros.exceptions.LibRouterosError, OSError, - socket.timeout, + TimeoutError, ) as api_error: _LOGGER.error("Mikrotik %s error: %s", entry[CONF_HOST], api_error) if "invalid user name or password" in str(api_error): diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 0e2debda33ec80..2cd6c51546ab29 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -14,7 +14,7 @@ Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er @@ -41,9 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.async_initialize() except MinecraftServerAddressError as error: - raise ConfigEntryError( - f"Server address in configuration entry is invalid: {error}" - ) from error + raise ConfigEntryNotReady(f"Initialization failed: {error}") from error # Create coordinator instance. coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) @@ -86,9 +84,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate config entry. _LOGGER.debug("Migrating config entry. Resetting unique ID: %s", old_unique_id) - config_entry.unique_id = None - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry) + hass.config_entries.async_update_entry(config_entry, unique_id=None, version=2) # Migrate device. await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) @@ -142,8 +138,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_data[CONF_ADDRESS] = address del new_data[CONF_HOST] del new_data[CONF_PORT] - config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry, data=new_data) + hass.config_entries.async_update_entry(config_entry, data=new_data, version=3) _LOGGER.debug("Migration to version 3 successful") diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index a2b2de4eda8757..d424df620cf751 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -149,7 +149,7 @@ async def async_camera_image( image = await response.read() return image - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout getting camera image from %s", self.name) except aiohttp.ClientError as err: @@ -169,7 +169,7 @@ async def _async_digest_camera_image(self) -> bytes | None: try: if self._still_image_url: # Fallback to MJPEG stream if still image URL is not available - with suppress(asyncio.TimeoutError, httpx.HTTPError): + with suppress(TimeoutError, httpx.HTTPError): return ( await client.get( self._still_image_url, auth=auth, timeout=TIMEOUT @@ -183,7 +183,7 @@ async def _async_digest_camera_image(self) -> bytes | None: stream.aiter_bytes(BUFFER_SIZE) ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.error("Timeout getting camera image from %s", self.name) except httpx.HTTPError as err: @@ -201,7 +201,7 @@ async def _handle_async_mjpeg_digest_stream( response = web.StreamResponse(headers=stream.headers) await response.prepare(request) # Stream until we are done or client disconnects - with suppress(asyncio.TimeoutError, httpx.HTTPError): + with suppress(TimeoutError, httpx.HTTPError): async for chunk in stream.aiter_bytes(BUFFER_SIZE): if not self.hass.is_running: break diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index aeab576a7cd27a..c3d15be3468d17 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/mobile_app", + "import_executor": true, "iot_class": "local_push", "loggers": ["nacl"], "quality_scale": "internal", diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 164f21af15a331..e6f7126b0b8cac 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -197,7 +197,7 @@ async def _async_send_remote_message_target(self, target, registration, data): else: _LOGGER.error(message) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout sending notification to %s", push_url) except aiohttp.ClientError as err: _LOGGER.error("Error sending notification to %s: %r", push_url, err) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 1151a5f1f013db..e5bb9e8bf38a07 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -134,7 +134,9 @@ from .modbus import ModbusHub, async_modbus_setup from .validators import ( check_config, + check_hvac_target_temp_registers, duplicate_fan_mode_validator, + hvac_fixedsize_reglist_validator, nan_validator, register_int_list_validator, struct_validator, @@ -239,7 +241,7 @@ CLIMATE_SCHEMA = vol.All( BASE_STRUCT_SCHEMA.extend( { - vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): hvac_fixedsize_reglist_validator, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float), vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float), @@ -296,8 +298,9 @@ duplicate_fan_mode_validator, ), ), - } + }, ), + check_hvac_target_temp_registers, ) COVERS_SCHEMA = BASE_COMPONENT_SCHEMA.extend( diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index d31323a27e9c78..a57fe53ada7ed6 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -75,6 +75,17 @@ PARALLEL_UPDATES = 1 +HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY = { + HVACMode.AUTO: 0, + HVACMode.COOL: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.HEAT: 4, + HVACMode.HEAT_COOL: 5, + HVACMode.OFF: 6, + None: 0, +} + async def async_setup_platform( hass: HomeAssistant, @@ -117,7 +128,6 @@ def __init__( CONF_TARGET_TEMP_WRITE_REGISTERS ] self._unit = config[CONF_TEMPERATURE_UNIT] - self._attr_current_temperature = None self._attr_target_temperature = None self._attr_temperature_unit = ( @@ -157,7 +167,6 @@ def __init__( for value in values: self._hvac_mode_mapping.append((value, hvac_mode)) self._attr_hvac_modes.append(hvac_mode) - else: # No HVAC modes defined self._hvac_mode_register = None @@ -305,21 +314,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None: if self._target_temperature_write_registers: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], [int(float(registers[0]))], CALL_TYPE_WRITE_REGISTERS, ) else: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], int(float(registers[0])), CALL_TYPE_WRITE_REGISTER, ) else: result = await self._hub.async_pb_call( self._slave, - self._target_temperature_register, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], [int(float(i)) for i in registers], CALL_TYPE_WRITE_REGISTERS, ) @@ -332,12 +347,15 @@ async def async_update(self, now: datetime | None = None) -> None: # async_track_time_interval self._attr_target_temperature = await self._async_read_register( - CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register + CALL_TYPE_REGISTER_HOLDING, + self._target_temperature_register[ + HVACMODE_TO_TARG_TEMP_REG_INDEX_ARRAY[self._attr_hvac_mode] + ], ) + self._attr_current_temperature = await self._async_read_register( self._input_type, self._address ) - # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 194eb56757ec6d..b90f5663643153 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.6.3"] + "requirements": ["pymodbus==3.6.4"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 71631352d5288a..c8e7fc3765ed0c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -172,9 +172,7 @@ async def async_write_register(service: ServiceCall) -> None: slave = int(float(service.data[ATTR_SLAVE])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] - hub = hub_collect[ - service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ] + hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] if isinstance(value, list): await hub.async_pb_call( slave, @@ -196,9 +194,7 @@ async def async_write_coil(service: ServiceCall) -> None: slave = int(float(service.data[ATTR_SLAVE])) address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] - hub = hub_collect[ - service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB - ] + hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)] if isinstance(state, list): await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) else: diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 76d8e270ffe1f6..765ce4d8be391a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.components.climate import HVACMode from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND_OFF, @@ -29,6 +30,7 @@ CONF_FAN_MODE_REGISTER, CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_REGISTER, + CONF_HVAC_ONOFF_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, @@ -172,6 +174,26 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: } +def hvac_fixedsize_reglist_validator(value: Any) -> list: + """Check the number of registers for target temp. and coerce it to a list, if valid.""" + if isinstance(value, int): + value = [value] * len(HVACMode) + return list(value) + + if len(value) == len(HVACMode): + _rv = True + for svalue in value: + if isinstance(svalue, int) is False: + _rv = False + break + if _rv is True: + return list(value) + + raise vol.Invalid( + f"Invalid target temp register. Required type: integer, allowed 1 or list of {len(HVACMode)} registers" + ) + + def nan_validator(value: Any) -> int: """Convert nan string to number (can be hex string or int).""" if isinstance(value, int): @@ -203,138 +225,31 @@ def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: return config -def scan_interval_validator(config: dict) -> dict: - """Control scan_interval.""" - for hub in config: - minimum_scan_interval = DEFAULT_SCAN_INTERVAL - for component, conf_key in PLATFORMS: - if conf_key not in hub: - continue - - for entry in hub[conf_key]: - scan_interval = entry.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - if scan_interval == 0: - continue - if scan_interval < 5: - _LOGGER.warning( - ( - "%s %s scan_interval(%d) is lower than 5 seconds, " - "which may cause Home Assistant stability issues" - ), - component, - entry.get(CONF_NAME), - scan_interval, - ) - entry[CONF_SCAN_INTERVAL] = scan_interval - minimum_scan_interval = min(scan_interval, minimum_scan_interval) - if ( - CONF_TIMEOUT in hub - and hub[CONF_TIMEOUT] > minimum_scan_interval - 1 - and minimum_scan_interval > 1 - ): - _LOGGER.warning( - "Modbus %s timeout(%d) is adjusted(%d) due to scan_interval", - hub.get(CONF_NAME, ""), - hub[CONF_TIMEOUT], - minimum_scan_interval - 1, - ) - hub[CONF_TIMEOUT] = minimum_scan_interval - 1 - return config - - -def duplicate_entity_validator(config: dict) -> dict: - """Control scan_interval.""" - for hub_index, hub in enumerate(config): - for component, conf_key in PLATFORMS: - if conf_key not in hub: - continue - names: set[str] = set() - errors: list[int] = [] - addresses: set[str] = set() - for index, entry in enumerate(hub[conf_key]): - name = entry[CONF_NAME] - addr = str(entry[CONF_ADDRESS]) - if CONF_INPUT_TYPE in entry: - addr += "_" + str(entry[CONF_INPUT_TYPE]) - elif CONF_WRITE_TYPE in entry: - addr += "_" + str(entry[CONF_WRITE_TYPE]) - if CONF_COMMAND_ON in entry: - addr += "_" + str(entry[CONF_COMMAND_ON]) - if CONF_COMMAND_OFF in entry: - addr += "_" + str(entry[CONF_COMMAND_OFF]) - inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) - addr += "_" + str(inx) - entry_addrs: set[str] = set() - entry_addrs.add(addr) - - if CONF_TARGET_TEMP in entry: - a = str(entry[CONF_TARGET_TEMP]) - a += "_" + str(inx) - entry_addrs.add(a) - if CONF_HVAC_MODE_REGISTER in entry: - a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) - a += "_" + str(inx) - entry_addrs.add(a) - if CONF_FAN_MODE_REGISTER in entry: - a = str( - entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS] - if isinstance(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS], int) - else entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS][0] - ) - a += "_" + str(inx) - entry_addrs.add(a) - - dup_addrs = entry_addrs.intersection(addresses) - - if len(dup_addrs) > 0: - for addr in dup_addrs: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) - errors.append(index) - elif name in names: - err = ( - f"Modbus {component}/{name}  is duplicate, second entry not" - " loaded!" - ) - _LOGGER.warning(err) - errors.append(index) - else: - names.add(name) - addresses.update(entry_addrs) +def check_hvac_target_temp_registers(config: dict) -> dict: + """Check conflicts among HVAC target temperature registers and HVAC ON/OFF, HVAC register, Fan Modes.""" - for i in reversed(errors): - del config[hub_index][conf_key][i] - return config - - -def duplicate_modbus_validator(config: dict) -> dict: - """Control modbus connection for duplicates.""" - hosts: set[str] = set() - names: set[str] = set() - errors = [] - for index, hub in enumerate(config): - name = hub.get(CONF_NAME, DEFAULT_HUB) - if hub[CONF_TYPE] == SERIAL: - host = hub[CONF_PORT] - else: - host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" - if host in hosts: - err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" - _LOGGER.warning(err) - errors.append(index) - elif name in names: - err = f"Modbus {name} is duplicate, second entry not loaded!" - _LOGGER.warning(err) - errors.append(index) - else: - hosts.add(host) - names.add(name) + if ( + CONF_HVAC_MODE_REGISTER in config + and config[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_HVAC_MODE_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_HVAC_MODE_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_HVAC_MODE_REGISTER] + if ( + CONF_HVAC_ONOFF_REGISTER in config + and config[CONF_HVAC_ONOFF_REGISTER] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_HVAC_ONOFF_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_HVAC_ONOFF_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_HVAC_ONOFF_REGISTER] + if ( + CONF_FAN_MODE_REGISTER in config + and config[CONF_FAN_MODE_REGISTER][CONF_ADDRESS] in config[CONF_TARGET_TEMP] + ): + wrn = f"{CONF_FAN_MODE_REGISTER} overlaps CONF_TARGET_TEMP register(s). {CONF_FAN_MODE_REGISTER} is not loaded!" + _LOGGER.warning(wrn) + del config[CONF_FAN_MODE_REGISTER] - for i in reversed(errors): - del config[i] return config @@ -354,7 +269,129 @@ def register_int_list_validator(value: Any) -> Any: def check_config(config: dict) -> dict: """Do final config check.""" - config2 = duplicate_modbus_validator(config) - config3 = scan_interval_validator(config2) - config4 = duplicate_entity_validator(config3) - return config4 + hosts: set[str] = set() + hub_names: set[str] = set() + hub_name_inx = 0 + minimum_scan_interval = 0 + ent_names: set[str] = set() + ent_addr: set[str] = set() + + def validate_modbus(hub: dict, hub_name_inx: int) -> bool: + """Validate modbus entries.""" + host: str = ( + hub[CONF_PORT] + if hub[CONF_TYPE] == SERIAL + else f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" + ) + if CONF_NAME not in hub: + hub[CONF_NAME] = ( + DEFAULT_HUB if not hub_name_inx else f"{DEFAULT_HUB}_{hub_name_inx}" + ) + hub_name_inx += 1 + err = f"Modbus host/port {host} is missing name, added {hub[CONF_NAME]}!" + _LOGGER.warning(err) + name = hub[CONF_NAME] + if host in hosts or name in hub_names: + err = f"Modbus {name} host/port {host} is duplicate, not loaded!" + _LOGGER.warning(err) + return False + hosts.add(host) + hub_names.add(name) + return True + + def validate_entity( + hub_name: str, + entity: dict, + minimum_scan_interval: int, + ent_names: set, + ent_addr: set, + ) -> bool: + """Validate entity.""" + name = entity[CONF_NAME] + addr = f"{hub_name}{entity[CONF_ADDRESS]}" + scan_interval = entity.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + if scan_interval < 5: + _LOGGER.warning( + ( + "%s %s scan_interval(%d) is lower than 5 seconds, " + "which may cause Home Assistant stability issues" + ), + hub_name, + name, + scan_interval, + ) + entity[CONF_SCAN_INTERVAL] = scan_interval + minimum_scan_interval = min(scan_interval, minimum_scan_interval) + for conf_type in ( + CONF_INPUT_TYPE, + CONF_WRITE_TYPE, + CONF_COMMAND_ON, + CONF_COMMAND_OFF, + ): + if conf_type in entity: + addr += f"_{entity[conf_type]}" + inx = entity.get(CONF_SLAVE, None) or entity.get(CONF_DEVICE_ADDRESS, 0) + addr += f"_{inx}" + loc_addr: set[str] = {addr} + + if CONF_TARGET_TEMP in entity: + loc_addr.add(f"{hub_name}{entity[CONF_TARGET_TEMP]}_{inx}") + if CONF_HVAC_MODE_REGISTER in entity: + loc_addr.add( + f"{hub_name}{entity[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]}_{inx}" + ) + if CONF_FAN_MODE_REGISTER in entity: + loc_addr.add( + f"{hub_name}{entity[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]}_{inx}" + ) + + dup_addrs = ent_addr.intersection(loc_addr) + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {hub_name}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) + return False + if name in ent_names: + err = f"Modbus {hub_name}/{name} is duplicate, second entry not loaded!" + _LOGGER.warning(err) + return False + ent_names.add(name) + ent_addr.update(loc_addr) + return True + + hub_inx = 0 + while hub_inx < len(config): + hub = config[hub_inx] + if not validate_modbus(hub, hub_name_inx): + del config[hub_inx] + continue + for _component, conf_key in PLATFORMS: + if conf_key not in hub: + continue + entity_inx = 0 + entities = hub[conf_key] + minimum_scan_interval = 9999 + while entity_inx < len(entities): + if not validate_entity( + hub[CONF_NAME], + entities[entity_inx], + minimum_scan_interval, + ent_names, + ent_addr, + ): + del entities[entity_inx] + else: + entity_inx += 1 + + if hub[CONF_TIMEOUT] >= minimum_scan_interval: + hub[CONF_TIMEOUT] = minimum_scan_interval - 1 + _LOGGER.warning( + "Modbus %s timeout is adjusted(%d) due to scan_interval", + hub[CONF_NAME], + hub[CONF_TIMEOUT], + ) + hub_inx += 1 + return config diff --git a/homeassistant/components/moehlenhoff_alpha2/config_flow.py b/homeassistant/components/moehlenhoff_alpha2/config_flow.py index d2d14f27552ade..a4bdfd71cce88e 100644 --- a/homeassistant/components/moehlenhoff_alpha2/config_flow.py +++ b/homeassistant/components/moehlenhoff_alpha2/config_flow.py @@ -1,5 +1,4 @@ """Alpha2 config flow.""" -import asyncio import logging from typing import Any @@ -27,7 +26,7 @@ async def validate_input(data: dict[str, Any]) -> dict[str, str]: base = Alpha2Base(data[CONF_HOST]) try: await base.update_data() - except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError): + except (aiohttp.client_exceptions.ClientConnectorError, TimeoutError): return {"error": "cannot_connect"} except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index d4d59f836742e8..22b430731e0abc 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -23,6 +23,20 @@ "waning_gibbous": "Waning gibbous", "waxing_crescent": "Waxing crescent", "waxing_gibbous": "Waxing gibbous" + }, + "state_attributes": { + "options": { + "state": { + "first_quarter": "[%key:component::moon::entity::sensor::phase::state::first_quarter%]", + "full_moon": "[%key:component::moon::entity::sensor::phase::state::full_moon%]", + "last_quarter": "[%key:component::moon::entity::sensor::phase::state::last_quarter%]", + "new_moon": "[%key:component::moon::entity::sensor::phase::state::new_moon%]", + "waning_crescent": "[%key:component::moon::entity::sensor::phase::state::waning_crescent%]", + "waning_gibbous": "[%key:component::moon::entity::sensor::phase::state::waning_gibbous%]", + "waxing_crescent": "[%key:component::moon::entity::sensor::phase::state::waxing_crescent%]", + "waxing_gibbous": "[%key:component::moon::entity::sensor::phase::state::waxing_gibbous%]" + } + } } } } diff --git a/homeassistant/components/mopeka/manifest.json b/homeassistant/components/mopeka/manifest.json index 69452bf1fecb53..82afd4d2057ce0 100644 --- a/homeassistant/components/mopeka/manifest.json +++ b/homeassistant/components/mopeka/manifest.json @@ -8,12 +8,48 @@ "manufacturer_data_start": [3], "connectable": false }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [4], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [5], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [6], + "connectable": false + }, { "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", "manufacturer_id": 89, "manufacturer_data_start": [8], "connectable": false }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [9], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [10], + "connectable": false + }, + { + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 89, + "manufacturer_data_start": [11], + "connectable": false + }, { "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", "manufacturer_id": 89, @@ -27,5 +63,5 @@ "documentation": "https://www.home-assistant.io/integrations/mopeka", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mopeka-iot-ble==0.5.0"] + "requirements": ["mopeka-iot-ble==0.7.0"] } diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 45b1e42c8bb0b4..a4868c0a21030e 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -56,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, data=data) _LOGGER.debug( ( - "Motion Blinds interface updated from %s to %s, " + "Motionblinds interface updated from %s to %s, " "this should only occur after a network change" ), multicast_interface, diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index d93e009136944a..588d470bb6c694 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow to configure Motion Blinds using their WLAN API.""" +"""Config flow to configure Motionblinds using their WLAN API.""" from __future__ import annotations from typing import Any @@ -62,12 +62,12 @@ async def async_step_init( class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a Motion Blinds config flow.""" + """Handle a Motionblinds config flow.""" VERSION = 1 def __init__(self) -> None: - """Initialize the Motion Blinds flow.""" + """Initialize the Motionblinds flow.""" self._host: str | None = None self._ips: list[str] = [] self._config_settings = None diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 429259a91c1bc9..4d9f8a7934def4 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -1,9 +1,9 @@ -"""Constants for the Motion Blinds component.""" +"""Constants for the Motionblinds component.""" from homeassistant.const import Platform DOMAIN = "motion_blinds" -MANUFACTURER = "Motion Blinds, Coulisse B.V." -DEFAULT_GATEWAY_NAME = "Motion Blinds Gateway" +MANUFACTURER = "Motionblinds, Coulisse B.V." +DEFAULT_GATEWAY_NAME = "Motionblinds Gateway" PLATFORMS = [Platform.COVER, Platform.SENSOR] diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index e8dc5494f257d8..f0cb67a6261437 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -1,8 +1,7 @@ -"""DataUpdateCoordinator for motion blinds integration.""" +"""DataUpdateCoordinator for Motionblinds integration.""" import asyncio from datetime import timedelta import logging -from socket import timeout from typing import Any from motionblinds import DEVICE_TYPES_WIFI, ParseException @@ -50,7 +49,7 @@ def update_gateway(self): """Fetch data from gateway.""" try: self._gateway.Update() - except (timeout, ParseException): + except (TimeoutError, ParseException): # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} @@ -65,7 +64,7 @@ def update_blind(self, blind): blind.Update() else: blind.Update_trigger() - except (timeout, ParseException): + except (TimeoutError, ParseException): # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 9dde08af5f00c6..60d8aae2ff8083 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -1,4 +1,4 @@ -"""Support for Motion Blinds using their WLAN API.""" +"""Support for Motionblinds using their WLAN API.""" from __future__ import annotations import logging @@ -87,7 +87,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Motion Blind from a config entry.""" - entities = [] + entities: list[MotionBaseDevice] = [] motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] @@ -168,10 +168,9 @@ async def async_setup_entry( ) -class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): - """Representation of a Motion Blind Device.""" +class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity): + """Representation of a Motionblinds Device.""" - _attr_name = None _restore_tilt = False def __init__(self, coordinator, blind, device_class): @@ -305,9 +304,15 @@ async def async_stop_cover(self, **kwargs: Any) -> None: await self.async_request_position_till_stop(delay=UPDATE_DELAY_STOP) -class MotionTiltDevice(MotionPositionDevice): +class MotionPositionDevice(MotionBaseDevice): """Representation of a Motion Blind Device.""" + _attr_name = None + + +class MotionTiltDevice(MotionPositionDevice): + """Representation of a Motionblinds Device.""" + _restore_tilt = True @property @@ -352,7 +357,7 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: class MotionTiltOnlyDevice(MotionTiltDevice): - """Representation of a Motion Blind Device.""" + """Representation of a Motionblinds Device.""" _restore_tilt = False @@ -394,13 +399,12 @@ async def async_set_absolute_position(self, **kwargs): ) -class MotionTDBUDevice(MotionPositionDevice): +class MotionTDBUDevice(MotionBaseDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" def __init__(self, coordinator, blind, device_class, motor): """Initialize the blind.""" super().__init__(coordinator, blind, device_class) - delattr(self, "_attr_name") self._motor = motor self._motor_key = motor[0] self._attr_translation_key = motor.lower() diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index 56eccb04eae447..36c45c3afc2013 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -1,4 +1,4 @@ -"""Support for Motion Blinds using their WLAN API.""" +"""Support for Motionblinds using their WLAN API.""" from __future__ import annotations from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway @@ -20,7 +20,7 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): - """Representation of a Motion Blind entity.""" + """Representation of a Motionblind entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index ac18840ddeb486..ff37b64012718d 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -50,7 +50,7 @@ async def async_connect_gateway(self, host, key): try: # update device info and get the connected sub devices await self._hass.async_add_executor_job(self.update_gateway) - except socket.timeout: + except TimeoutError: _LOGGER.error( "Timeout trying to connect to Motion Gateway with host %s", host ) @@ -100,7 +100,7 @@ async def async_check_interface(self, host, key): interfaces = await self.async_get_interfaces() for interface in interfaces: _LOGGER.debug( - "Checking Motion Blinds interface '%s' with host %s", interface, host + "Checking Motionblinds interface '%s' with host %s", interface, host ) # initialize multicast listener check_multicast = AsyncMotionMulticast(interface=interface) @@ -126,7 +126,7 @@ async def async_check_interface(self, host, key): if result: # successfully received multicast _LOGGER.debug( - "Success using Motion Blinds interface '%s' with host %s", + "Success using Motionblinds interface '%s' with host %s", interface, host, ) @@ -134,7 +134,7 @@ async def async_check_interface(self, host, key): _LOGGER.error( ( - "Could not find working interface for Motion Blinds host %s, using" + "Could not find working interface for Motionblinds host %s, using" " interface '%s'" ), host, diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index fa333d9060f925..0f9241db7b4a49 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -1,6 +1,6 @@ { "domain": "motion_blinds", - "name": "Motion Blinds", + "name": "Motionblinds", "codeowners": ["@starkillerOG"], "config_flow": true, "dependencies": ["network"], @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.21"] + "requirements": ["motionblinds==0.6.23"] } diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index dddcb0e00fdd37..b746b39bdf0113 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,8 +1,12 @@ -"""Support for Motion Blinds sensors.""" +"""Support for Motionblinds sensors.""" from motionblinds import DEVICE_TYPES_WIFI from motionblinds.motion_blinds import DEVICE_TYPE_TDBU -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, @@ -23,7 +27,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Perform the setup for Motion Blinds.""" + """Perform the setup for Motionblinds.""" entities: list[SensorEntity] = [] motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] @@ -50,6 +54,7 @@ class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" diff --git a/homeassistant/components/motion_blinds/services.yaml b/homeassistant/components/motion_blinds/services.yaml index 7b18979ed0ed8a..ef6fe99b7229a7 100644 --- a/homeassistant/components/motion_blinds/services.yaml +++ b/homeassistant/components/motion_blinds/services.yaml @@ -1,4 +1,4 @@ -# Describes the format for available motion blinds services +# Describes the format for available Motionblinds services set_absolute_position: target: diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index 9b3adb38e0c5b0..0721afa9d3a840 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -128,18 +128,16 @@ async def connection(self): try: async with asyncio.timeout(self._client.timeout + 5): await self._client.connect(self.server, self.port) - except asyncio.TimeoutError as error: + except TimeoutError as error: # TimeoutError has no message (which hinders logging further # down the line), so provide one. - raise asyncio.TimeoutError( - "Connection attempt timed out" - ) from error + raise TimeoutError("Connection attempt timed out") from error if self.password is not None: await self._client.password(self.password) self._is_available = True yield except ( - asyncio.TimeoutError, + TimeoutError, gaierror, mpd.ConnectionError, OSError, diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 593d5bbd2029c5..1412ad63e687cd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -7,7 +7,6 @@ import logging from typing import TYPE_CHECKING, Any, TypeVar, cast -import jinja2 import voluptuous as vol from homeassistant import config as conf_util @@ -27,7 +26,6 @@ from homeassistant.exceptions import ( ConfigValidationError, ServiceValidationError, - TemplateError, Unauthorized, ) from homeassistant.helpers import config_validation as cv, event as ev, template @@ -87,11 +85,13 @@ MQTT_DISCONNECTED, PLATFORMS, RELOADABLE_PLATFORMS, + TEMPLATE_ERRORS, ) from .models import ( # noqa: F401 MqttCommandTemplate, MqttData, MqttValueTemplate, + PayloadSentinel, PublishPayloadType, ReceiveMessage, ReceivePayloadType, @@ -320,49 +320,30 @@ async def async_publish_service(call: ServiceCall) -> None: qos: int = call.data[ATTR_QOS] retain: bool = call.data[ATTR_RETAIN] if msg_topic_template is not None: + rendered_topic: Any = MqttCommandTemplate( + template.Template(msg_topic_template), + hass=hass, + ).async_render() try: - rendered_topic: Any = template.Template( - msg_topic_template, hass - ).async_render(parse_result=False) msg_topic = valid_publish_topic(rendered_topic) - except (jinja2.TemplateError, TemplateError) as exc: - _LOGGER.error( - ( - "Unable to publish: rendering topic template of %s " - "failed because %s" - ), - msg_topic_template, - exc, - ) - return except vol.Invalid as err: - _LOGGER.error( - ( - "Unable to publish: topic template '%s' produced an " - "invalid topic '%s' after rendering (%s)" - ), - msg_topic_template, - rendered_topic, - err, - ) - return + err_str = str(err) + raise ServiceValidationError( + f"Unable to publish: topic template '{msg_topic_template}' produced an " + f"invalid topic '{rendered_topic}' after rendering ({err_str})", + translation_domain=DOMAIN, + translation_key="invalid_publish_topic", + translation_placeholders={ + "error": err_str, + "topic": str(rendered_topic), + "topic_template": str(msg_topic_template), + }, + ) from err if payload_template is not None: - try: - payload = MqttCommandTemplate( - template.Template(payload_template), hass=hass - ).async_render() - except (jinja2.TemplateError, TemplateError) as exc: - _LOGGER.error( - ( - "Unable to publish to %s: rendering payload template of " - "%s failed because %s" - ), - msg_topic, - payload_template, - exc, - ) - return + payload = MqttCommandTemplate( + template.Template(payload_template), hass=hass + ).async_render() if TYPE_CHECKING: assert msg_topic is not None @@ -544,7 +525,7 @@ def forward_messages(mqttmsg: ReceiveMessage) -> None: ) # Perform UTF-8 decoding directly in callback routine - qos: int = msg["qos"] if "qos" in msg else DEFAULT_QOS + qos: int = msg.get("qos", DEFAULT_QOS) connection.subscriptions[msg["id"]] = await async_subscribe( hass, msg["topic"], forward_messages, encoding=None, qos=qos ) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 164632cdd10671..ace3cf9fd6486f 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -818,25 +818,29 @@ def _matching_subscriptions(self, topic: str) -> list[Subscription]: @callback def _mqtt_handle_message(self, msg: mqtt.MQTTMessage) -> None: + topic = msg.topic + # msg.topic is a property that decodes the topic to a string + # every time it is accessed. Save the result to avoid + # decoding the same topic multiple times. _LOGGER.debug( "Received%s message on %s (qos=%s): %s", " retained" if msg.retain else "", - msg.topic, + topic, msg.qos, msg.payload[0:8192], ) timestamp = dt_util.utcnow() - subscriptions = self._matching_subscriptions(msg.topic) + subscriptions = self._matching_subscriptions(topic) for subscription in subscriptions: if msg.retain: retained_topics = self._retained_topics.setdefault(subscription, set()) # Skip if the subscription already received a retained message - if msg.topic in retained_topics: + if topic in retained_topics: continue # Remember the subscription had an initial retained message - self._retained_topics[subscription].add(msg.topic) + self._retained_topics[subscription].add(topic) payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: @@ -846,7 +850,7 @@ def _mqtt_handle_message(self, msg: mqtt.MQTTMessage) -> None: _LOGGER.warning( "Can't decode payload %s on %s with encoding %s (for %s)", msg.payload[0:8192], - msg.topic, + topic, subscription.encoding, subscription.job, ) @@ -854,7 +858,7 @@ def _mqtt_handle_message(self, msg: mqtt.MQTTMessage) -> None: self.hass.async_run_hass_job( subscription.job, ReceiveMessage( - msg.topic, + topic, payload, msg.qos, msg.retain, @@ -917,7 +921,7 @@ async def _wait_for_mid(self, mid: int) -> None: try: async with asyncio.timeout(TIMEOUT_ACK): await self._pending_operations[mid].wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid ) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 94311eeda612be..4e85163767ce08 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,6 @@ 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.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter @@ -77,7 +76,6 @@ CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, - DOMAIN, PAYLOAD_NONE, ) from .debug_info import log_messages @@ -98,13 +96,11 @@ _LOGGER = logging.getLogger(__name__) -MQTT_CLIMATE_AUX_DOCS = "https://www.home-assistant.io/integrations/climate.mqtt/" - DEFAULT_NAME = "MQTT HVAC" # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 -# Support will be removed in HA Core 2024.3 +# Support was removed in HA Core 2024.3 CONF_AUX_COMMAND_TOPIC = "aux_command_topic" CONF_AUX_STATE_TEMPLATE = "aux_state_template" CONF_AUX_STATE_TOPIC = "aux_state_topic" @@ -150,7 +146,6 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( { - climate.ATTR_AUX_HEAT, climate.ATTR_CURRENT_HUMIDITY, climate.ATTR_CURRENT_TEMPERATURE, climate.ATTR_FAN_MODE, @@ -174,13 +169,11 @@ ) VALUE_TEMPLATE_KEYS = ( - CONF_AUX_STATE_TEMPLATE, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_TEMP_TEMPLATE, CONF_FAN_MODE_STATE_TEMPLATE, CONF_HUMIDITY_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, - CONF_POWER_STATE_TEMPLATE, CONF_ACTION_TEMPLATE, CONF_PRESET_MODE_VALUE_TEMPLATE, CONF_SWING_MODE_STATE_TEMPLATE, @@ -204,8 +197,6 @@ TOPIC_KEYS = ( CONF_ACTION_TOPIC, - CONF_AUX_COMMAND_TOPIC, - CONF_AUX_STATE_TOPIC, CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TOPIC, CONF_FAN_MODE_COMMAND_TOPIC, @@ -266,12 +257,6 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - vol.Optional(CONF_AUX_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_AUX_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_CURRENT_HUMIDITY_TEMPLATE): cv.template, vol.Optional(CONF_CURRENT_HUMIDITY_TOPIC): valid_subscribe_topic, vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, @@ -369,10 +354,10 @@ def valid_humidity_state_configuration(config: ConfigType) -> ConfigType: cv.removed(CONF_POWER_STATE_TOPIC), # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - cv.deprecated(CONF_AUX_COMMAND_TOPIC), - cv.deprecated(CONF_AUX_STATE_TEMPLATE), - cv.deprecated(CONF_AUX_STATE_TOPIC), + # Support was removed in HA Core 2024.3 + cv.removed(CONF_AUX_COMMAND_TOPIC), + cv.removed(CONF_AUX_STATE_TEMPLATE), + cv.removed(CONF_AUX_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -603,7 +588,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _attr_fan_mode: str | None = None _attr_hvac_mode: HVACMode | None = None - _attr_is_aux_heat: bool | None = None _attr_swing_mode: str | None = None _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT @@ -662,11 +646,6 @@ def _setup_from_config(self, config: ConfigType) -> None: self._attr_swing_mode = SWING_OFF if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic: self._attr_hvac_mode = HVACMode.OFF - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - if self._topic[CONF_AUX_STATE_TOPIC] is None or self._optimistic: - self._attr_is_aux_heat = False self._feature_preset_mode = CONF_PRESET_MODE_COMMAND_TOPIC in config if self._feature_preset_mode: presets = [] @@ -736,32 +715,8 @@ def _setup_from_config(self, config: ConfigType) -> None: if self._feature_preset_mode: support |= ClimateEntityFeature.PRESET_MODE - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - if (self._topic[CONF_AUX_STATE_TOPIC] is not None) or ( - self._topic[CONF_AUX_COMMAND_TOPIC] is not None - ): - support |= ClimateEntityFeature.AUX_HEAT self._attr_supported_features = support - async def mqtt_async_added_to_hass(self) -> None: - """Handle deprecation issues.""" - if self._attr_supported_features & ClimateEntityFeature.AUX_HEAT: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_climate_aux_property_{self.entity_id}", - breaks_in_ha_version="2024.3.0", - is_fixable=False, - translation_key="deprecated_climate_aux_property", - translation_placeholders={ - "entity_id": self.entity_id, - }, - learn_more_url=MQTT_CLIMATE_AUX_DOCS, - severity=IssueSeverity.WARNING, - ) - def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} @@ -875,41 +830,6 @@ def handle_swing_mode_received(msg: ReceiveMessage) -> None: topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received ) - @callback - def handle_onoff_mode_received( - msg: ReceiveMessage, template_name: str, attr: str - ) -> None: - """Handle receiving on/off mode via MQTT.""" - payload = self.render_template(msg, template_name) - payload_on: str = self._config[CONF_PAYLOAD_ON] - payload_off: str = self._config[CONF_PAYLOAD_OFF] - - if payload == "True": - payload = payload_on - elif payload == "False": - payload = payload_off - - if payload == payload_on: - setattr(self, attr, True) - elif payload == payload_off: - setattr(self, attr, False) - else: - _LOGGER.error("Invalid %s mode: %s", attr, payload) - - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - @callback - @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_aux_heat"}) - def handle_aux_mode_received(msg: ReceiveMessage) -> None: - """Handle receiving aux mode via MQTT.""" - handle_onoff_mode_received( - msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat" - ) - - self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) - @callback @log_messages(self.hass, self.entity_id) @write_state_on_attr_change(self, {"_attr_preset_mode"}) @@ -1002,27 +922,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: self._attr_preset_mode = preset_mode self.async_write_ha_state() - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support will be removed in HA Core 2024.3 - async def _set_aux_heat(self, state: bool) -> None: - await self._publish( - CONF_AUX_COMMAND_TOPIC, - self._config[CONF_PAYLOAD_ON] if state else self._config[CONF_PAYLOAD_OFF], - ) - - if self._optimistic or self._topic[CONF_AUX_STATE_TOPIC] is None: - self._attr_is_aux_heat = state - self.async_write_ha_state() - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - await self._set_aux_heat(True) - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - await self._set_aux_heat(False) - async def async_turn_on(self) -> None: """Turn the entity on.""" if CONF_POWER_COMMAND_TOPIC in self._config: diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index fba2f13937e9c4..7f97910961d4a3 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,6 +1,9 @@ """Constants used by multiple MQTT modules.""" +import jinja2 + from homeassistant.const import CONF_PAYLOAD, Platform +from homeassistant.exceptions import TemplateError ATTR_DISCOVERY_HASH = "discovery_hash" ATTR_DISCOVERY_PAYLOAD = "discovery_payload" @@ -194,3 +197,5 @@ Platform.VALVE, Platform.WATER_HEATER, ] + +TEMPLATE_ERRORS = (jinja2.TemplateError, TemplateError, TypeError, ValueError) diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 351eb422edcdeb..c245b66fdb136a 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -29,6 +29,7 @@ CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, + TEMPLATE_ERRORS, ) from .debug_info import log_messages from .mixins import ( @@ -131,7 +132,10 @@ def message_received(msg: ReceiveMessage) -> None: return event_attributes: dict[str, Any] = {} event_type: str - payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + try: + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + except TEMPLATE_ERRORS: + return if ( not payload or payload is PayloadSentinel.DEFAULT diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 1f90f0fdb3d870..e91a8c5c259cf6 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -24,7 +24,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_ENCODING, CONF_QOS +from .const import CONF_ENCODING, CONF_QOS, TEMPLATE_ERRORS from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, @@ -188,10 +188,11 @@ def image_data_received(msg: ReceiveMessage) -> None: @log_messages(self.hass, self.entity_id) def image_from_url_request_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" - try: url = cv.url(self._url_template(msg.payload)) self._attr_image_url = url + except TEMPLATE_ERRORS: + return except vol.Invalid: _LOGGER.error( "Invalid image URL '%s' received at topic %s", diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index 3a284c6719c29b..2fc77fb1d4a670 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["file_upload", "http"], "documentation": "https://www.home-assistant.io/integrations/mqtt", + "import_executor": true, "iot_class": "local_push", "quality_scale": "gold", "requirements": ["paho-mqtt==1.6.1"] diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 4c7837a7a2bbb4..5736f821f693e1 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -94,6 +94,7 @@ DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, + TEMPLATE_ERRORS, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -110,7 +111,6 @@ MqttValueTemplate, PublishPayloadType, ReceiveMessage, - ReceivePayloadType, ) from .subscription import ( EntitySubscription, @@ -480,7 +480,10 @@ def wrapper(msg: ReceiveMessage) -> None: attribute: getattr(entity, attribute, UNDEFINED) for attribute in attributes } - msg_callback(msg) + try: + msg_callback(msg) + except TEMPLATE_ERRORS: + return if not _attrs_have_changed(tracked_attrs): return @@ -527,8 +530,9 @@ def _attributes_prepare_subscribe_topics(self) -> None: @log_messages(self.hass, self.entity_id) @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) def attributes_message_received(msg: ReceiveMessage) -> None: + """Update extra state attributes.""" + payload = attr_tpl(msg.payload) try: - payload = attr_tpl(msg.payload) json_dict = json_loads(payload) if isinstance(payload, str) else None if isinstance(json_dict, dict): filtered_dict = { @@ -636,7 +640,6 @@ def _availability_prepare_subscribe_topics(self) -> None: def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic - payload: ReceivePayloadType payload = self._avail_topics[topic][CONF_AVAILABILITY_TEMPLATE](msg.payload) if payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: self._available[topic] = True @@ -646,8 +649,7 @@ def availability_message_received(msg: ReceiveMessage) -> None: self._available_latest = False self._available = { - topic: (self._available[topic] if topic in self._available else False) - for topic in self._avail_topics + topic: (self._available.get(topic, False)) for topic in self._avail_topics } topics: dict[str, dict[str, Any]] = { f"availability_{topic}": { @@ -1345,15 +1347,12 @@ def async_removed_from_device( config_entry_id: str, ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - if event.data["action"] not in ("remove", "update"): - return False - if event.data["action"] == "update": if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) if ( - device_entry := device_registry.async_get(event.data["device_id"]) + device_entry := device_registry.async_get(mqtt_device_id) ) and config_entry_id in device_entry.config_entries: # Not removed from device return False diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 0d009cf356b4eb..1295bfb8ff3d72 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -15,6 +15,7 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError, TemplateError from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType @@ -29,6 +30,8 @@ from .discovery import MQTTDiscoveryPayload from .tag import MQTTTagScanner +from .const import DOMAIN, TEMPLATE_ERRORS + class PayloadSentinel(StrEnum): """Sentinel for `async_render_with_possible_json_value`.""" @@ -109,6 +112,38 @@ class MqttOriginInfo(TypedDict, total=False): support_url: str +class MqttCommandTemplateException(ServiceValidationError): + """Handle MqttCommandTemplate exceptions.""" + + def __init__( + self, + *args: object, + base_exception: Exception, + command_template: str, + value: PublishPayloadType, + entity_id: str | None = None, + ) -> None: + """Initialize exception.""" + super().__init__(base_exception, *args) + value_log = str(value) + self.translation_domain = DOMAIN + self.translation_key = "command_template_error" + self.translation_placeholders = { + "error": str(base_exception), + "entity_id": str(entity_id), + "command_template": command_template, + } + entity_id_log = "" if entity_id is None else f" for entity '{entity_id}'" + self._message = ( + f"{type(base_exception).__name__}: {base_exception} rendering template{entity_id_log}" + f", template: '{command_template}' and payload: {value_log}" + ) + + def __str__(self) -> str: + """Return exception message string.""" + return self._message + + class MqttCommandTemplate: """Class for rendering MQTT payload with command templates.""" @@ -175,9 +210,17 @@ def _convert_outgoing_payload( values, self._command_template, ) - return _convert_outgoing_payload( - self._command_template.async_render(values, parse_result=False) - ) + try: + return _convert_outgoing_payload( + self._command_template.async_render(values, parse_result=False) + ) + except TemplateError as exc: + raise MqttCommandTemplateException( + base_exception=exc, + command_template=self._command_template.template, + value=value, + entity_id=self._entity.entity_id if self._entity is not None else None, + ) from exc class MqttValueTemplate: @@ -247,7 +290,7 @@ def async_render_with_possible_json_value( payload, variables=values ) ) - except Exception as exc: + except TEMPLATE_ERRORS as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: '%s'", type(exc).__name__, @@ -255,7 +298,7 @@ def async_render_with_possible_json_value( self._entity.entity_id if self._entity else "n/a", self._value_template.template, ) - raise exc + raise return rendered_payload _LOGGER.debug( @@ -274,18 +317,18 @@ def async_render_with_possible_json_value( payload, default, variables=values ) ) - except Exception as ex: + except TEMPLATE_ERRORS as exc: _LOGGER.error( "%s: %s rendering template for entity '%s', template: " "'%s', default value: %s and payload: %s", - type(ex).__name__, - ex, + type(exc).__name__, + exc, self._entity.entity_id if self._entity else "n/a", self._value_template.template, default, payload, ) - raise ex + raise return rendered_payload diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ce892e97026628..4c37de8204cae5 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -4,10 +4,6 @@ "title": "MQTT vacuum entities with deprecated `schema` config settings found in your configuration.yaml", "description": "The `schema` setting for MQTT vacuum entities is deprecated and should be removed. Please adjust your configuration.yaml and restart Home Assistant to fix this issue." }, - "deprecated_climate_aux_property": { - "title": "MQTT entities with auxiliary heat support found", - "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." - }, "invalid_platform_config": { "title": "Invalid config found for mqtt {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." @@ -250,9 +246,15 @@ } }, "exceptions": { + "command_template_error": { + "message": "Parsing template `{command_template}` for entity `{entity_id}` failed with error: {error}." + }, "invalid_platform_config": { "message": "Reloading YAML config for manually configured MQTT `{domain}` item failed. See logs for more details." }, + "invalid_publish_topic": { + "message": "Unable to publish: topic template `{topic_template}` produced an invalid topic `{topic}` after rendering ({error})" + }, "mqtt_not_setup_cannot_subscribe": { "message": "Cannot subscribe to topic '{topic}', make sure MQTT is set up correctly." }, diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 80a717b1f37c5f..0eda584e95a063 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -15,7 +15,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC +from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC, TEMPLATE_ERRORS from .discovery import MQTTDiscoveryPayload from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -136,7 +136,10 @@ async def subscribe_topics(self) -> None: """Subscribe to MQTT topics.""" async def tag_scanned(msg: ReceiveMessage) -> None: - tag_id = str(self._value_template(msg.payload, "")).strip() + try: + tag_id = str(self._value_template(msg.payload, "")).strip() + except TEMPLATE_ERRORS: + return if not tag_id: # No output from template, ignore return diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index f478ad712d77cc..fb47bbfc667182 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -74,7 +74,7 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: async with asyncio.timeout(AVAILABILITY_TIMEOUT): # Await the client setup or an error state was received return await state_reached_future - except asyncio.TimeoutError: + except TimeoutError: return False diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py index e06c0b07c87724..21bbcfe69bb1fa 100644 --- a/homeassistant/components/mutesync/config_flow.py +++ b/homeassistant/components/mutesync/config_flow.py @@ -32,7 +32,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, if error.status == 403: raise InvalidAuth from error raise CannotConnect from error - except (aiohttp.ClientError, asyncio.TimeoutError) as error: + except (aiohttp.ClientError, TimeoutError) as error: raise CannotConnect from error return token diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 0818d68de2bac1..28cacbe7762aaa 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -109,7 +109,7 @@ def on_conn_made(_: BaseAsyncGateway) -> None: async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() return True - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.info("Try gateway connect failed with timeout") return False finally: @@ -301,7 +301,7 @@ async def stop_this_gw(_: Event) -> None: try: async with asyncio.timeout(GATEWAY_READY_TIMEOUT): await gateway_ready.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Gateway %s not connected after %s secs so continuing with setup", entry.data[CONF_DEVICE], diff --git a/homeassistant/components/myuplink/__init__.py b/homeassistant/components/myuplink/__init__.py index 15ae1eb75c2923..fcfffc54b3108e 100644 --- a/homeassistant/components/myuplink/__init__.py +++ b/homeassistant/components/myuplink/__init__.py @@ -1,11 +1,15 @@ """The myUplink integration.""" from __future__ import annotations -from myuplink.api import MyUplinkAPI +from http import HTTPStatus + +from aiohttp import ClientError, ClientResponseError +from myuplink import MyUplinkAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, @@ -13,10 +17,16 @@ ) from .api import AsyncConfigEntryAuth -from .const import DOMAIN +from .const import DOMAIN, OAUTH2_SCOPES from .coordinator import MyUplinkDataCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -31,6 +41,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, implementation) auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + try: + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + + if set(config_entry.data["token"]["scope"].split(" ")) != set(OAUTH2_SCOPES): + raise ConfigEntryAuthFailed("Incorrect OAuth2 scope") + # Setup MyUplinkAPI and coordinator for data fetch api = MyUplinkAPI(auth) coordinator = MyUplinkDataCoordinator(hass, api) diff --git a/homeassistant/components/myuplink/api.py b/homeassistant/components/myuplink/api.py index 5d0fcaf521a848..1b74d41bc97f4b 100644 --- a/homeassistant/components/myuplink/api.py +++ b/homeassistant/components/myuplink/api.py @@ -11,7 +11,7 @@ from .const import API_ENDPOINT -class AsyncConfigEntryAuth(AbstractAuth): # type: ignore[misc] +class AsyncConfigEntryAuth(AbstractAuth): """Provide myUplink authentication tied to an OAuth2 based config entry.""" def __init__( diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py new file mode 100644 index 00000000000000..b5ade88a0029e5 --- /dev/null +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -0,0 +1,100 @@ +"""Binary sensors for myUplink.""" + +from myuplink import DevicePoint + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity +from .helpers import find_matching_platform + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { + "NIBEF": { + "43161": BinarySensorEntityDescription( + key="elect_add", + icon="mdi:electric-switch", + ), + }, +} + + +def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Default to None + """ + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + + return description + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink binary_sensor.""" + entities: list[BinarySensorEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point sensors + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + if find_matching_platform(device_point) == Platform.BINARY_SENSOR: + description = get_description(device_point) + + entities.append( + MyUplinkDevicePointBinarySensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) + ) + async_add_entities(entities) + + +class MyUplinkDevicePointBinarySensor(MyUplinkEntity, BinarySensorEntity): + """Representation of a myUplink device point binary sensor.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: BinarySensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the binary_sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + + if entity_description is not None: + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Binary sensor state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return int(device_point.value) != 0 diff --git a/homeassistant/components/myuplink/config_flow.py b/homeassistant/components/myuplink/config_flow.py index e8377f2682bf34..c108aa00ebe0db 100644 --- a/homeassistant/components/myuplink/config_flow.py +++ b/homeassistant/components/myuplink/config_flow.py @@ -1,7 +1,10 @@ """Config flow for myUplink.""" +from collections.abc import Mapping import logging from typing import Any +from homeassistant.config_entries import ConfigEntry +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -14,6 +17,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + config_entry_reauth: ConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -23,3 +28,30 @@ def logger(self) -> logging.Logger: def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return {"scope": " ".join(OAUTH2_SCOPES)} + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.config_entry_reauth = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + ) + + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create or update the config entry.""" + if self.config_entry_reauth: + return self.async_update_reload_and_abort( + self.config_entry_reauth, + data=data, + ) + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py index 9adb1eb0e30b61..3541a8078c3c46 100644 --- a/homeassistant/components/myuplink/const.py +++ b/homeassistant/components/myuplink/const.py @@ -5,4 +5,4 @@ API_ENDPOINT = "https://api.myuplink.com" OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" -OAUTH2_SCOPES = ["READSYSTEM", "offline_access"] +OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"] diff --git a/homeassistant/components/myuplink/coordinator.py b/homeassistant/components/myuplink/coordinator.py index 4cd66adab2ba93..03a902fc4bb417 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -4,8 +4,7 @@ from datetime import datetime, timedelta import logging -from myuplink.api import MyUplinkAPI -from myuplink.models import Device, DevicePoint, System +from myuplink import Device, DevicePoint, MyUplinkAPI, System from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator diff --git a/homeassistant/components/myuplink/diagnostics.py b/homeassistant/components/myuplink/diagnostics.py new file mode 100644 index 00000000000000..55cbb07c0d02aa --- /dev/null +++ b/homeassistant/components/myuplink/diagnostics.py @@ -0,0 +1,46 @@ +"""Diagnostics support for myUplink.""" +from __future__ import annotations + +from typing import Any + +from myuplink import MyUplinkAPI + +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 = {"access_token", "refresh_token", "serialNumber"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry. + + Pick up fresh data from API and dump it. + """ + api: MyUplinkAPI = hass.data[DOMAIN][config_entry.entry_id].api + myuplink_data = {} + myuplink_data["my_systems"] = await api.async_get_systems_json() + myuplink_data["my_systems"]["devices"] = [] + for system in myuplink_data["my_systems"]["systems"]: + for device in system["devices"]: + device_data = await api.async_get_device_json(device["id"]) + device_points = await api.async_get_device_points_json(device["id"]) + myuplink_data["my_systems"]["devices"].append( + { + system["systemId"]: { + "device_data": device_data, + "points": device_points, + } + } + ) + + diagnostics_data = { + "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT), + "myuplink_data": async_redact_data(myuplink_data, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py new file mode 100644 index 00000000000000..8b16dacfd34c0c --- /dev/null +++ b/homeassistant/components/myuplink/helpers.py @@ -0,0 +1,33 @@ +"""Helper collection for myuplink.""" + +from myuplink import DevicePoint + +from homeassistant.components.number import NumberEntityDescription +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import Platform + + +def find_matching_platform( + device_point: DevicePoint, + description: SensorEntityDescription | NumberEntityDescription | None = None, +) -> Platform: + """Find entity platform for a DevicePoint.""" + if ( + len(device_point.enum_values) == 2 + and device_point.enum_values[0]["value"] == "0" + and device_point.enum_values[1]["value"] == "1" + ): + if device_point.writable: + return Platform.SWITCH + return Platform.BINARY_SENSOR + + if ( + description + and description.native_unit_of_measurement == "DM" + or (device_point.raw["maxValue"] and device_point.raw["minValue"]) + ): + if device_point.writable: + return Platform.NUMBER + return Platform.SENSOR + + return Platform.SENSOR diff --git a/homeassistant/components/myuplink/manifest.json b/homeassistant/components/myuplink/manifest.json index 303af547335562..a76f596ade356c 100644 --- a/homeassistant/components/myuplink/manifest.json +++ b/homeassistant/components/myuplink/manifest.json @@ -1,10 +1,10 @@ { "domain": "myuplink", "name": "myUplink", - "codeowners": ["@pajzo"], + "codeowners": ["@pajzo", "@astrandb"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/myuplink", "iot_class": "cloud_polling", - "requirements": ["myuplink==0.0.9"] + "requirements": ["myuplink==0.5.0"] } diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py new file mode 100644 index 00000000000000..ddfcdb109d49e0 --- /dev/null +++ b/homeassistant/components/myuplink/number.py @@ -0,0 +1,132 @@ +"""Number entity for myUplink.""" + + +from aiohttp import ClientError +from myuplink import DevicePoint + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity +from .helpers import find_matching_platform + +DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = { + "DM": NumberEntityDescription( + key="degree_minutes", + icon="mdi:thermometer-lines", + native_unit_of_measurement="DM", + ), +} + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = { + "NIBEF": { + "40940": NumberEntityDescription( + key="degree_minutes", + icon="mdi:thermometer-lines", + native_unit_of_measurement="DM", + ), + }, +} + + +def get_description(device_point: DevicePoint) -> NumberEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Global parameter_unit e.g. "DM" + 3. Default to None + """ + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + + if description is None: + description = DEVICE_POINT_UNIT_DESCRIPTIONS.get(device_point.parameter_unit) + + return description + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink number.""" + entities: list[NumberEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point number entities + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + description = get_description(device_point) + if find_matching_platform(device_point, description) == Platform.NUMBER: + entities.append( + MyUplinkNumber( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) + ) + + async_add_entities(entities) + + +class MyUplinkNumber(MyUplinkEntity, NumberEntity): + """Representation of a myUplink number entity.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: NumberEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the number.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + self._attr_native_min_value = ( + device_point.raw["minValue"] if device_point.raw["minValue"] else -30000 + ) * float(device_point.raw.get("scaleValue", 1)) + self._attr_native_max_value = ( + device_point.raw["maxValue"] if device_point.raw["maxValue"] else 30000 + ) * float(device_point.raw.get("scaleValue", 1)) + self._attr_step_value = device_point.raw.get("stepValue", 20) + if entity_description is not None: + self.entity_description = entity_description + + @property + def native_value(self) -> float: + """Number state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return float(device_point.value) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.coordinator.api.async_set_device_points( + self.device_id, data={self.point_id: str(value)} + ) + except ClientError as err: + raise HomeAssistantError( + f"Failed to set new value {value} for {self.point_id}/{self.entity_id}" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 5b08b26a3061a7..1e4bfed1a20638 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -1,6 +1,6 @@ """Sensor for myUplink.""" -from myuplink.models import DevicePoint +from myuplink import DevicePoint from homeassistant.components.sensor import ( SensorDeviceClass, @@ -9,7 +9,17 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTemperature +from homeassistant.const import ( + Platform, + UnitOfElectricCurrent, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -17,40 +27,161 @@ from . import MyUplinkDataCoordinator from .const import DOMAIN from .entity import MyUplinkEntity +from .helpers import find_matching_platform -DEVICE_POINT_DESCRIPTIONS = { +DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { "°C": SensorEntityDescription( key="celsius", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + "°F": SensorEntityDescription( + key="fahrenheit", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + ), + "A": SensorEntityDescription( + key="ampere", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + ), + "bar": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + ), + "h": SensorEntityDescription( + key="hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.HOURS, + suggested_display_precision=1, + ), + "Hz": SensorEntityDescription( + key="hertz", + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + ), + "kW": SensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + ), + "kWh": SensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ), + "m3/h": SensorEntityDescription( + key="airflow", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + icon="mdi:weather-windy", + ), + "s": SensorEntityDescription( + key="seconds", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_display_precision=0, + ), +} + +MARKER_FOR_UNKNOWN_VALUE = -32768 + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = { + "NIBEF": { + "43108": SensorEntityDescription( + key="fan_mode", + icon="mdi:fan", + ), + "43427": SensorEntityDescription( + key="status_compressor", + device_class=SensorDeviceClass.ENUM, + icon="mdi:heat-pump-outline", + ), + "49993": SensorEntityDescription( + key="elect_add", + device_class=SensorDeviceClass.ENUM, + icon="mdi:heat-wave", + ), + "49994": SensorEntityDescription( + key="priority", + device_class=SensorDeviceClass.ENUM, + icon="mdi:priority-high", + ), + }, + "NIBE": {}, } +def get_description(device_point: DevicePoint) -> SensorEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Global parameter_unit e.g. "°C" + 3. Default to None + """ + description = None + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + if description is None: + description = DEVICE_POINT_UNIT_DESCRIPTIONS.get(device_point.parameter_unit) + + return description + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up myUplink sensor.""" + entities: list[SensorEntity] = [] coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] # Setup device point sensors for device_id, point_data in coordinator.data.points.items(): for point_id, device_point in point_data.items(): - entities.append( - MyUplinkDevicePointSensor( - coordinator=coordinator, - device_id=device_id, - device_point=device_point, - entity_description=DEVICE_POINT_DESCRIPTIONS.get( - device_point.parameter_unit - ), - unique_id_suffix=point_id, + if find_matching_platform(device_point) == Platform.SENSOR: + description = get_description(device_point) + entity_class = MyUplinkDevicePointSensor + if ( + description is not None + and description.device_class == SensorDeviceClass.ENUM + ): + entities.append( + MyUplinkEnumRawSensor( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=f"{point_id}-raw", + ) + ) + entity_class = MyUplinkEnumSensor + + entities.append( + entity_class( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) ) - ) async_add_entities(entities) @@ -75,7 +206,8 @@ def __init__( # Internal properties self.point_id = device_point.parameter_id - self._attr_name = device_point.parameter_name.replace("\u002d", "") + # Remove soft hyphens + self._attr_name = device_point.parameter_name.replace("\u00ad", "") if entity_description is not None: self.entity_description = entity_description @@ -86,4 +218,64 @@ def __init__( def native_value(self) -> StateType: """Sensor state value.""" device_point = self.coordinator.data.points[self.device_id][self.point_id] + if device_point.value == MARKER_FOR_UNKNOWN_VALUE: + return None return device_point.value # type: ignore[no-any-return] + + +class MyUplinkEnumSensor(MyUplinkDevicePointSensor): + """Representation of a myUplink device point sensor for ENUM device_class.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=entity_description, + unique_id_suffix=unique_id_suffix, + ) + + self._attr_options = [x["text"].capitalize() for x in device_point.enum_values] + self.options_map = { + x["value"]: x["text"].capitalize() for x in device_point.enum_values + } + + @property + def native_value(self) -> str: + """Sensor state value for enum sensor.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return self.options_map[str(int(device_point.value))] # type: ignore[no-any-return] + + +class MyUplinkEnumRawSensor(MyUplinkDevicePointSensor): + """Representation of a myUplink device point sensor for raw value from ENUM device_class.""" + + _attr_entity_registry_enabled_default = False + _attr_device_class = None + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SensorEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the sensor.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=entity_description, + unique_id_suffix=unique_id_suffix, + ) + + self._attr_name = f"{device_point.parameter_name} raw" diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 569e148a5a3af8..f01bb1990cc9bb 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -3,6 +3,10 @@ "step": { "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The myUplink integration needs to re-authenticate your account" } }, "abort": { @@ -12,7 +16,8 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py new file mode 100644 index 00000000000000..310c6417133642 --- /dev/null +++ b/homeassistant/components/myuplink/switch.py @@ -0,0 +1,123 @@ +"""Switch entity for myUplink.""" + +from typing import Any + +import aiohttp +from myuplink import DevicePoint + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity +from .helpers import find_matching_platform + +CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = { + "NIBEF": { + "50004": SwitchEntityDescription( + key="temporary_lux", + icon="mdi:water-alert-outline", + ), + }, +} + + +def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None: + """Get description for a device point. + + Priorities: + 1. Category specific prefix e.g "NIBEF" + 2. Default to None + """ + prefix, _, _ = device_point.category.partition(" ") + description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( + device_point.parameter_id + ) + + return description + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up myUplink switch.""" + entities: list[SwitchEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup device point switches + for device_id, point_data in coordinator.data.points.items(): + for point_id, device_point in point_data.items(): + if find_matching_platform(device_point) == Platform.SWITCH: + description = get_description(device_point) + + entities.append( + MyUplinkDevicePointSwitch( + coordinator=coordinator, + device_id=device_id, + device_point=device_point, + entity_description=description, + unique_id_suffix=point_id, + ) + ) + + async_add_entities(entities) + + +class MyUplinkDevicePointSwitch(MyUplinkEntity, SwitchEntity): + """Representation of a myUplink device point switch.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + device_point: DevicePoint, + entity_description: SwitchEntityDescription | None, + unique_id_suffix: str, + ) -> None: + """Initialize the switch.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + # Internal properties + self.point_id = device_point.parameter_id + self._attr_name = device_point.parameter_name + + if entity_description is not None: + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Switch state value.""" + device_point = self.coordinator.data.points[self.device_id][self.point_id] + return int(device_point.value) != 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_turn_switch(1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_turn_switch(0) + + async def _async_turn_switch(self, mode: int) -> None: + """Set switch mode.""" + try: + await self.coordinator.api.async_set_device_points( + self.device_id, data={self.point_id: mode} + ) + except aiohttp.ClientError as err: + raise HomeAssistantError( + f"Failed to set state for {self.entity_id}" + ) from err + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py new file mode 100644 index 00000000000000..2b779e833864e9 --- /dev/null +++ b/homeassistant/components/myuplink/update.py @@ -0,0 +1,72 @@ +"""Update entity for myUplink.""" + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import MyUplinkDataCoordinator +from .const import DOMAIN +from .entity import MyUplinkEntity + +UPDATE_DESCRIPTION = UpdateEntityDescription( + key="update", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up update entity.""" + entities: list[UpdateEntity] = [] + coordinator: MyUplinkDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + # Setup update entities + for device_id in coordinator.data.devices: + entities.append( + MyUplinkDeviceUpdate( + coordinator=coordinator, + device_id=device_id, + entity_description=UPDATE_DESCRIPTION, + unique_id_suffix="upd", + ) + ) + + async_add_entities(entities) + + +class MyUplinkDeviceUpdate(MyUplinkEntity, UpdateEntity): + """Representation of a myUplink device update entity.""" + + def __init__( + self, + coordinator: MyUplinkDataCoordinator, + device_id: str, + entity_description: UpdateEntityDescription, + unique_id_suffix: str, + ) -> None: + """Initialize the update entity.""" + super().__init__( + coordinator=coordinator, + device_id=device_id, + unique_id_suffix=unique_id_suffix, + ) + + self.entity_description = entity_description + + @property + def installed_version(self) -> str | None: + """Return installed_version.""" + return self.coordinator.data.devices[self.device_id].firmwareCurrent + + @property + def latest_version(self) -> str | None: + """Return latest_version.""" + return self.coordinator.data.devices[self.device_id].firmwareDesired diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 28f9c282a7383d..9df1b93a4d76e1 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err: raise ConfigEntryNotReady from err try: diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 7eee84a66a4b22..8f44c28df3a3e1 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -92,7 +92,7 @@ async def async_step_user( try: config = await async_get_config(self.hass, self.host) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") @@ -128,7 +128,7 @@ async def async_step_credentials( await async_check_credentials(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") @@ -155,7 +155,7 @@ async def async_step_zeroconf( try: self._config = await async_get_config(self.hass, self.host) - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") @@ -209,7 +209,7 @@ async def async_step_reauth_confirm( ApiError, AuthFailedError, ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, ): return self.async_abort(reason="reauth_unsuccessful") diff --git a/homeassistant/components/nam/icons.json b/homeassistant/components/nam/icons.json new file mode 100644 index 00000000000000..5e55bf145e5761 --- /dev/null +++ b/homeassistant/components/nam/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "pmsx003_caqi": { + "default": "mdi:air-filter" + }, + "pmsx003_caqi_level": { + "default": "mdi:air-filter" + }, + "sds011_caqi": { + "default": "mdi:air-filter" + }, + "sds011_caqi_level": { + "default": "mdi:air-filter" + }, + "sps30_caqi": { + "default": "mdi:air-filter" + }, + "sps30_caqi_level": { + "default": "mdi:air-filter" + }, + "sps30_pm4": { + "default": "mdi:molecule" + } + } + } +} diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 5b3c6517f645bd..cd1543affa2e4a 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -180,13 +180,11 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI, translation_key="pmsx003_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.pms_caqi, ), NAMSensorEntityDescription( key=ATTR_PMSX003_CAQI_LEVEL, translation_key="pmsx003_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.pms_caqi_level, @@ -221,13 +219,11 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM NAMSensorEntityDescription( key=ATTR_SDS011_CAQI, translation_key="sds011_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.sds011_caqi, ), NAMSensorEntityDescription( key=ATTR_SDS011_CAQI_LEVEL, translation_key="sds011_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.sds011_caqi_level, @@ -271,13 +267,11 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM NAMSensorEntityDescription( key=ATTR_SPS30_CAQI, translation_key="sps30_caqi", - icon="mdi:air-filter", value=lambda sensors: sensors.sps30_caqi, ), NAMSensorEntityDescription( key=ATTR_SPS30_CAQI_LEVEL, translation_key="sps30_caqi_level", - icon="mdi:air-filter", device_class=SensorDeviceClass.ENUM, options=["very_low", "low", "medium", "high", "very_high"], value=lambda sensors: sensors.sps30_caqi_level, @@ -314,7 +308,6 @@ class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysM translation_key="sps30_pm4", suggested_display_precision=0, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - icon="mdi:molecule", state_class=SensorStateClass.MEASUREMENT, value=lambda sensors: sensors.sps30_p4, ), diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 92feaba13aaee6..a4f77f59e25c28 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -100,9 +100,7 @@ def _handle_arming_state_change( self._attr_state = None elif arming_state == ArmingState.DISARMED: self._attr_state = STATE_ALARM_DISARMED - elif arming_state == ArmingState.ARMING: - self._attr_state = STATE_ALARM_ARMING - elif arming_state == ArmingState.EXIT_DELAY: + elif arming_state in (ArmingState.ARMING, ArmingState.EXIT_DELAY): self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.ARMED: self._attr_state = ARMING_MODE_TO_STATE.get( diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index bfc77a09548fe4..42d4ced67928f4 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,7 +1,6 @@ """The Netatmo data handler.""" from __future__ import annotations -import asyncio from collections import deque from dataclasses import dataclass from datetime import datetime, timedelta @@ -239,7 +238,7 @@ async def async_fetch_data(self, signal_name: str) -> bool: _LOGGER.debug(err) has_error = True - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as err: + except (TimeoutError, aiohttp.ClientConnectorError) as err: _LOGGER.debug(err) return True diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 1ab7a48e1b3273..0e33cd9c95271a 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -197,13 +197,11 @@ async def async_update(self) -> None: _LOGGER.debug("Host %s has %s alarms", self.name, number_of_alarms) for alarm in alarms: - if alarms[alarm]["recipient"] == "silent": - number_of_relevant_alarms = number_of_relevant_alarms - 1 - elif alarms[alarm]["status"] == "CLEAR": - number_of_relevant_alarms = number_of_relevant_alarms - 1 - elif alarms[alarm]["status"] == "UNDEFINED": - number_of_relevant_alarms = number_of_relevant_alarms - 1 - elif alarms[alarm]["status"] == "UNINITIALIZED": + if alarms[alarm]["recipient"] == "silent" or alarms[alarm]["status"] in ( + "CLEAR", + "UNDEFINED", + "UNINITIALIZED", + ): number_of_relevant_alarms = number_of_relevant_alarms - 1 elif alarms[alarm]["status"] == "CRITICAL": self._state = "critical" diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 0644de58ee7b15..f1954eb50b8f1e 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -1,5 +1,4 @@ """Support for Nexia / Trane XL Thermostats.""" -import asyncio import logging import aiohttp @@ -45,7 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await nexia_home.login() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( f"Timed out trying to connect to Nexia service: {ex}" ) from ex diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index de5640beef7bd7..46dc1454a2af2b 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Nexia integration.""" -import asyncio import logging import aiohttp @@ -57,7 +56,7 @@ async def validate_input(hass: core.HomeAssistant, data): ) try: await nexia_home.login() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.error("Unable to connect to Nexia service: %s", ex) raise CannotConnect from ex except aiohttp.ClientResponseError as http_ex: diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 0013cd63de10b3..1384226eac1ef9 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -10,6 +10,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/nexia", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["nexia"], "requirements": ["nexia==2.0.8"] diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index ca59c7d0e3aa80..af972fb75094b9 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -163,7 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with asyncio.timeout(10): nextdns = await NextDns.create(websession, api_key) - except (ApiError, ClientConnectorError, asyncio.TimeoutError) as err: + except (ApiError, ClientConnectorError, TimeoutError) as err: raise ConfigEntryNotReady from err tasks = [] diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index c502f788a86778..b0a1d9367525a0 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -43,7 +43,7 @@ async def async_step_user( ) except InvalidApiKeyError: errors["base"] = "invalid_api_key" - except (ApiError, ClientConnectorError, asyncio.TimeoutError): + except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except errors["base"] = "unknown" diff --git a/homeassistant/components/nextdns/icons.json b/homeassistant/components/nextdns/icons.json new file mode 100644 index 00000000000000..b62629d3dc95fb --- /dev/null +++ b/homeassistant/components/nextdns/icons.json @@ -0,0 +1,266 @@ +{ + "entity": { + "sensor": { + "all_queries": { + "default": "mdi:dns" + }, + "blocked_queries": { + "default": "mdi:dns" + }, + "blocked_queries_ratio": { + "default": "mdi:dns" + }, + "doh_queries": { + "default": "mdi:dns" + }, + "doh_queries_ratio": { + "default": "mdi:dns" + }, + "doh3_queries": { + "default": "mdi:dns" + }, + "doh3_queries_ratio": { + "default": "mdi:dns" + }, + "doq_queries": { + "default": "mdi:dns" + }, + "doq_queries_ratio": { + "default": "mdi:dns" + }, + "dot_queries": { + "default": "mdi:dns" + }, + "dot_queries_ratio": { + "default": "mdi:dns" + }, + "encrypted_queries": { + "default": "mdi:lock" + }, + "encrypted_queries_ratio": { + "default": "mdi:lock" + }, + "ipv4_queries": { + "default": "mdi:ip" + }, + "ipv6_queries": { + "default": "mdi:ip" + }, + "ipv6_queries_ratio": { + "default": "mdi:ip" + }, + "relayed_queries": { + "default": "mdi:dns" + }, + "not_validated_queries": { + "default": "mdi:lock-alert" + }, + "tcp_queries": { + "default": "mdi:dns" + }, + "tcp_queries_ratio": { + "default": "mdi:dns" + }, + "udp_queries": { + "default": "mdi:dns" + }, + "udp_queries_ratio": { + "default": "mdi:dns" + }, + "unencrypted_queries": { + "default": "mdi:lock-open" + }, + "validated_queries": { + "default": "mdi:lock-check" + }, + "validated_queries_ratio": { + "default": "mdi:lock-check" + } + }, + "switch": { + "block_page": { + "default": "mdi:web-cancel" + }, + "cache_boost": { + "default": "mdi:memory" + }, + "cname_flattening": { + "default": "mdi:tournament" + }, + "anonymized_ecs": { + "default": "mdi:incognito" + }, + "logs": { + "default": "mdi:file-document-outline" + }, + "web3": { + "default": "mdi:web" + }, + "dns_rebinding_protection": { + "default": "mdi:dns" + }, + "google_safe_browsing": { + "default": "mdi:google" + }, + "typosquatting_protection": { + "default": "mdi:keyboard-outline" + }, + "safesearch": { + "default": "mdi:search-web" + }, + "youtube_restricted_mode": { + "default": "mdi:youtube" + }, + "block_9gag": { + "default": "mdi:file-gif-box" + }, + "block_amazon": { + "default": "mdi:cart-outline" + }, + "block_bereal": { + "default": "mdi:alpha-b-box" + }, + "block_blizzard": { + "default": "mdi:sword-cross" + }, + "block_chatgpt": { + "default": "mdi:chat-processing-outline" + }, + "block_dailymotion": { + "default": "mdi:movie-search-outline" + }, + "block_discord": { + "default": "mdi:message-text" + }, + "block_disneyplus": { + "default": "mdi:movie-search-outline" + }, + "block_ebay": { + "default": "mdi:basket-outline" + }, + "block_facebook": { + "default": "mdi:facebook" + }, + "block_fortnite": { + "default": "mdi:tank" + }, + "block_google_chat": { + "default": "mdi:forum" + }, + "block_hbomax": { + "default": "mdi:movie-search-outline" + }, + "block_hulu": { + "default": "mdi:hulu" + }, + "block_imgur": { + "default": "mdi:camera-image" + }, + "block_instagram": { + "default": "mdi:instagram" + }, + "block_leagueoflegends": { + "default": "mdi:sword" + }, + "block_mastodon": { + "default": "mdi:mastodon" + }, + "block_messenger": { + "default": "mdi:facebook-messenger" + }, + "block_minecraft": { + "default": "mdi:minecraft" + }, + "block_netflix": { + "default": "mdi:netflix" + }, + "block_pinterest": { + "default": "mdi:pinterest" + }, + "block_playstation_network": { + "default": "mdi:sony-playstation" + }, + "block_primevideo": { + "default": "mdi:filmstrip" + }, + "block_reddit": { + "default": "mdi:reddit" + }, + "block_roblox": { + "default": "mdi:robot" + }, + "block_signal": { + "default": "mdi:chat-outline" + }, + "block_skype": { + "default": "mdi:skype" + }, + "block_snapchat": { + "default": "mdi:snapchat" + }, + "block_spotify": { + "default": "mdi:spotify" + }, + "block_steam": { + "default": "mdi:steam" + }, + "block_telegram": { + "default": "mdi:send-outline" + }, + "block_tiktok": { + "default": "mdi:music-note" + }, + "block_tinder": { + "default": "mdi:fire" + }, + "block_tumblr": { + "default": "mdi:image-outline" + }, + "block_twitch": { + "default": "mdi:twitch" + }, + "block_twitter": { + "default": "mdi:twitter" + }, + "block_vimeo": { + "default": "mdi:vimeo" + }, + "block_vk": { + "default": "mdi:power-socket-eu" + }, + "block_whatsapp": { + "default": "mdi:whatsapp" + }, + "block_xboxlive": { + "default": "mdi:microsoft-xbox" + }, + "block_youtube": { + "default": "mdi:youtube" + }, + "block_zoom": { + "default": "mdi:video" + }, + "block_dating": { + "default": "mdi:candelabra" + }, + "block_gambling": { + "default": "mdi:slot-machine" + }, + "block_online_gaming": { + "default": "mdi:gamepad-variant" + }, + "block_piracy": { + "default": "mdi:pirate" + }, + "block_porn": { + "default": "mdi:movie-off" + }, + "block_social_networks": { + "default": "mdi:facebook" + }, + "block_video_streaming": { + "default": "mdi:video-wireless-outline" + } + } + } +} diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index c501142697eb91..b6864fea50abd3 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -59,7 +59,6 @@ class NextDnsSensorEntityDescription( key="all_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="all_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -69,7 +68,6 @@ class NextDnsSensorEntityDescription( key="blocked_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="blocked_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -79,7 +77,6 @@ class NextDnsSensorEntityDescription( key="relayed_queries", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="relayed_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -89,7 +86,6 @@ class NextDnsSensorEntityDescription( key="blocked_queries_ratio", coordinator_type=ATTR_STATUS, entity_category=EntityCategory.DIAGNOSTIC, - icon="mdi:dns", translation_key="blocked_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -100,7 +96,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doh_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -111,7 +106,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doh3_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -122,7 +116,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="dot_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -133,7 +126,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="doq_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -144,7 +136,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="tcp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -155,7 +146,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="udp_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -165,7 +155,6 @@ class NextDnsSensorEntityDescription( key="doh_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doh_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -176,7 +165,6 @@ class NextDnsSensorEntityDescription( key="doh3_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doh3_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -188,7 +176,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="dot_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -198,7 +185,6 @@ class NextDnsSensorEntityDescription( key="doq_queries_ratio", coordinator_type=ATTR_PROTOCOLS, entity_registry_enabled_default=False, - icon="mdi:dns", entity_category=EntityCategory.DIAGNOSTIC, translation_key="doq_queries_ratio", native_unit_of_measurement=PERCENTAGE, @@ -210,7 +196,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="tcp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -221,7 +206,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_PROTOCOLS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:dns", translation_key="udp_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -232,7 +216,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock", translation_key="encrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -243,7 +226,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-open", translation_key="unencrypted_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -254,7 +236,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_ENCRYPTION, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock", translation_key="encrypted_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -265,7 +246,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv4_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -276,7 +256,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv6_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -287,7 +266,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_IP_VERSIONS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:ip", translation_key="ipv6_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -298,7 +276,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-check", translation_key="validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -309,7 +286,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-alert", translation_key="not_validated_queries", native_unit_of_measurement="queries", state_class=SensorStateClass.TOTAL, @@ -320,7 +296,6 @@ class NextDnsSensorEntityDescription( coordinator_type=ATTR_DNSSEC, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - icon="mdi:lock-check", translation_key="validated_queries_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 177b4970a93b6e..a01b8a8c3c3000 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -1,7 +1,6 @@ """Support for the NextDNS service.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass from typing import Any, Generic @@ -43,42 +42,36 @@ class NextDnsSwitchEntityDescription( key="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", translation_key="cache_boost", entity_category=EntityCategory.CONFIG, - icon="mdi:memory", state=lambda data: data.cache_boost, ), NextDnsSwitchEntityDescription[Settings]( key="cname_flattening", translation_key="cname_flattening", entity_category=EntityCategory.CONFIG, - icon="mdi:tournament", state=lambda data: data.cname_flattening, ), NextDnsSwitchEntityDescription[Settings]( key="anonymized_ecs", translation_key="anonymized_ecs", entity_category=EntityCategory.CONFIG, - icon="mdi:incognito", state=lambda data: data.anonymized_ecs, ), NextDnsSwitchEntityDescription[Settings]( key="logs", translation_key="logs", entity_category=EntityCategory.CONFIG, - icon="mdi:file-document-outline", state=lambda data: data.logs, ), NextDnsSwitchEntityDescription[Settings]( key="web3", translation_key="web3", entity_category=EntityCategory.CONFIG, - icon="mdi:web", state=lambda data: data.web3, ), NextDnsSwitchEntityDescription[Settings]( @@ -139,14 +132,12 @@ class NextDnsSwitchEntityDescription( key="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", translation_key="google_safe_browsing", entity_category=EntityCategory.CONFIG, - icon="mdi:google", state=lambda data: data.google_safe_browsing, ), NextDnsSwitchEntityDescription[Settings]( @@ -165,7 +156,6 @@ class NextDnsSwitchEntityDescription( key="typosquatting_protection", translation_key="typosquatting_protection", entity_category=EntityCategory.CONFIG, - icon="mdi:keyboard-outline", state=lambda data: data.typosquatting_protection, ), NextDnsSwitchEntityDescription[Settings]( @@ -178,14 +168,12 @@ class NextDnsSwitchEntityDescription( key="safesearch", translation_key="safesearch", entity_category=EntityCategory.CONFIG, - icon="mdi:search-web", state=lambda data: data.safesearch, ), NextDnsSwitchEntityDescription[Settings]( key="youtube_restricted_mode", translation_key="youtube_restricted_mode", entity_category=EntityCategory.CONFIG, - icon="mdi:youtube", state=lambda data: data.youtube_restricted_mode, ), NextDnsSwitchEntityDescription[Settings]( @@ -193,7 +181,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_9gag", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:file-gif-box", state=lambda data: data.block_9gag, ), NextDnsSwitchEntityDescription[Settings]( @@ -201,7 +188,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_amazon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:cart-outline", state=lambda data: data.block_amazon, ), NextDnsSwitchEntityDescription[Settings]( @@ -209,7 +195,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_bereal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:alpha-b-box", state=lambda data: data.block_bereal, ), NextDnsSwitchEntityDescription[Settings]( @@ -217,7 +202,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_blizzard", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sword-cross", state=lambda data: data.block_blizzard, ), NextDnsSwitchEntityDescription[Settings]( @@ -225,7 +209,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_chatgpt", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:chat-processing-outline", state=lambda data: data.block_chatgpt, ), NextDnsSwitchEntityDescription[Settings]( @@ -233,7 +216,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_dailymotion", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_dailymotion, ), NextDnsSwitchEntityDescription[Settings]( @@ -241,7 +223,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_discord", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:message-text", state=lambda data: data.block_discord, ), NextDnsSwitchEntityDescription[Settings]( @@ -249,7 +230,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_disneyplus", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_disneyplus, ), NextDnsSwitchEntityDescription[Settings]( @@ -257,7 +237,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_ebay", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:basket-outline", state=lambda data: data.block_ebay, ), NextDnsSwitchEntityDescription[Settings]( @@ -265,7 +244,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_facebook", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:facebook", state=lambda data: data.block_facebook, ), NextDnsSwitchEntityDescription[Settings]( @@ -273,7 +251,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_fortnite", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:tank", state=lambda data: data.block_fortnite, ), NextDnsSwitchEntityDescription[Settings]( @@ -281,7 +258,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_google_chat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:forum", state=lambda data: data.block_google_chat, ), NextDnsSwitchEntityDescription[Settings]( @@ -289,7 +265,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_hbomax", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-search-outline", state=lambda data: data.block_hbomax, ), NextDnsSwitchEntityDescription[Settings]( @@ -297,7 +272,6 @@ class NextDnsSwitchEntityDescription( name="Block Hulu", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:hulu", state=lambda data: data.block_hulu, ), NextDnsSwitchEntityDescription[Settings]( @@ -305,7 +279,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_imgur", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:camera-image", state=lambda data: data.block_imgur, ), NextDnsSwitchEntityDescription[Settings]( @@ -313,7 +286,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_instagram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:instagram", state=lambda data: data.block_instagram, ), NextDnsSwitchEntityDescription[Settings]( @@ -321,7 +293,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_leagueoflegends", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sword", state=lambda data: data.block_leagueoflegends, ), NextDnsSwitchEntityDescription[Settings]( @@ -329,7 +300,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_mastodon", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:mastodon", state=lambda data: data.block_mastodon, ), NextDnsSwitchEntityDescription[Settings]( @@ -337,7 +307,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_messenger", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:message-text", state=lambda data: data.block_messenger, ), NextDnsSwitchEntityDescription[Settings]( @@ -345,7 +314,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_minecraft", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:minecraft", state=lambda data: data.block_minecraft, ), NextDnsSwitchEntityDescription[Settings]( @@ -353,7 +321,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_netflix", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:netflix", state=lambda data: data.block_netflix, ), NextDnsSwitchEntityDescription[Settings]( @@ -361,7 +328,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_pinterest", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:pinterest", state=lambda data: data.block_pinterest, ), NextDnsSwitchEntityDescription[Settings]( @@ -369,7 +335,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_playstation_network", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:sony-playstation", state=lambda data: data.block_playstation_network, ), NextDnsSwitchEntityDescription[Settings]( @@ -377,7 +342,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_primevideo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:filmstrip", state=lambda data: data.block_primevideo, ), NextDnsSwitchEntityDescription[Settings]( @@ -385,7 +349,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_reddit", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:reddit", state=lambda data: data.block_reddit, ), NextDnsSwitchEntityDescription[Settings]( @@ -393,7 +356,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_roblox", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:robot", state=lambda data: data.block_roblox, ), NextDnsSwitchEntityDescription[Settings]( @@ -401,7 +363,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_signal", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:chat-outline", state=lambda data: data.block_signal, ), NextDnsSwitchEntityDescription[Settings]( @@ -409,7 +370,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_skype", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:skype", state=lambda data: data.block_skype, ), NextDnsSwitchEntityDescription[Settings]( @@ -417,7 +377,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_snapchat", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:snapchat", state=lambda data: data.block_snapchat, ), NextDnsSwitchEntityDescription[Settings]( @@ -425,7 +384,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_spotify", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:spotify", state=lambda data: data.block_spotify, ), NextDnsSwitchEntityDescription[Settings]( @@ -433,7 +391,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_steam", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:steam", state=lambda data: data.block_steam, ), NextDnsSwitchEntityDescription[Settings]( @@ -441,7 +398,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_telegram", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:send-outline", state=lambda data: data.block_telegram, ), NextDnsSwitchEntityDescription[Settings]( @@ -449,7 +405,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_tiktok", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:music-note", state=lambda data: data.block_tiktok, ), NextDnsSwitchEntityDescription[Settings]( @@ -457,7 +412,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_tinder", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:fire", state=lambda data: data.block_tinder, ), NextDnsSwitchEntityDescription[Settings]( @@ -465,7 +419,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_tumblr", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:image-outline", state=lambda data: data.block_tumblr, ), NextDnsSwitchEntityDescription[Settings]( @@ -473,7 +426,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_twitch", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:twitch", state=lambda data: data.block_twitch, ), NextDnsSwitchEntityDescription[Settings]( @@ -481,7 +433,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_twitter", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:twitter", state=lambda data: data.block_twitter, ), NextDnsSwitchEntityDescription[Settings]( @@ -489,7 +440,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_vimeo", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:vimeo", state=lambda data: data.block_vimeo, ), NextDnsSwitchEntityDescription[Settings]( @@ -497,7 +447,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_vk", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:power-socket-eu", state=lambda data: data.block_vk, ), NextDnsSwitchEntityDescription[Settings]( @@ -505,7 +454,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_whatsapp", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:whatsapp", state=lambda data: data.block_whatsapp, ), NextDnsSwitchEntityDescription[Settings]( @@ -513,7 +461,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_xboxlive", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:microsoft-xbox", state=lambda data: data.block_xboxlive, ), NextDnsSwitchEntityDescription[Settings]( @@ -521,7 +468,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_youtube", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:youtube", state=lambda data: data.block_youtube, ), NextDnsSwitchEntityDescription[Settings]( @@ -529,7 +475,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_zoom", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:video", state=lambda data: data.block_zoom, ), NextDnsSwitchEntityDescription[Settings]( @@ -537,7 +482,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_dating", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:candelabra", state=lambda data: data.block_dating, ), NextDnsSwitchEntityDescription[Settings]( @@ -545,7 +489,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_gambling", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:slot-machine", state=lambda data: data.block_gambling, ), NextDnsSwitchEntityDescription[Settings]( @@ -553,7 +496,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_online_gaming", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:gamepad-variant", state=lambda data: data.block_online_gaming, ), NextDnsSwitchEntityDescription[Settings]( @@ -561,7 +503,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_piracy", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:pirate", state=lambda data: data.block_piracy, ), NextDnsSwitchEntityDescription[Settings]( @@ -569,7 +510,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_porn", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:movie-off", state=lambda data: data.block_porn, ), NextDnsSwitchEntityDescription[Settings]( @@ -577,7 +517,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_social_networks", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:facebook", state=lambda data: data.block_social_networks, ), NextDnsSwitchEntityDescription[Settings]( @@ -585,7 +524,6 @@ class NextDnsSwitchEntityDescription( translation_key="block_video_streaming", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - icon="mdi:video-wireless-outline", state=lambda data: data.block_video_streaming, ), ) @@ -647,7 +585,7 @@ async def async_set_setting(self, new_state: bool) -> None: except ( ApiError, ClientConnectorError, - asyncio.TimeoutError, + TimeoutError, ClientError, ) as err: raise HomeAssistantError( diff --git a/homeassistant/components/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py index 88f12ffa4bc6c9..798fcf1ec9dbbe 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -1,5 +1,4 @@ """The Nightscout integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI @@ -26,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = NightscoutAPI(server_url, session=session, api_secret=api_key) try: status = await api.get_server_status() - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/nightscout/config_flow.py b/homeassistant/components/nightscout/config_flow.py index 98e075ba3c9c21..6249979c83da5b 100644 --- a/homeassistant/components/nightscout/config_flow.py +++ b/homeassistant/components/nightscout/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Nightscout integration.""" -from asyncio import TimeoutError as AsyncIOTimeoutError import logging from typing import Any @@ -30,7 +29,7 @@ async def _validate_input(data: dict[str, Any]) -> dict[str, str]: await api.get_sgvs() except ClientResponseError as error: raise InputValidationError("invalid_auth") from error - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: raise InputValidationError("cannot_connect") from error # Return info to be stored in the config entry. diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 851610ee374c40..bdc46e75cb8cb0 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -1,7 +1,6 @@ """Support for Nightscout sensors.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError from datetime import timedelta import logging from typing import Any @@ -51,7 +50,7 @@ async def async_update(self) -> None: """Fetch the latest data from Nightscout REST API and update the state.""" try: values = await self.api.get_sgvs() - except (ClientError, AsyncIOTimeoutError, OSError) as error: + except (ClientError, TimeoutError, OSError) as error: _LOGGER.error("Error fetching data. Failed with %s", error) self._attr_available = False return diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 726b3fa3db8cdb..bab71df94dc7b1 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -2,16 +2,14 @@ from __future__ import annotations import asyncio -import contextlib from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import logging from typing import Final -import aiohttp +import aiooui from getmac import get_mac_address -from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError from homeassistant.components.device_tracker import ( @@ -158,7 +156,6 @@ def __init__( self._known_mac_addresses: dict[str, str] = {} self._finished_first_scan = False self._last_results: list[NmapDevice] = [] - self._mac_vendor_lookup = None async def async_setup(self): """Set up the tracker.""" @@ -191,8 +188,9 @@ async def async_setup(self): registry = er.async_get(self._hass) self._known_mac_addresses = { entry.unique_id: entry.original_name - for entry in registry.entities.values() - if entry.config_entry_id == self._entry_id + for entry in registry.entities.get_entries_for_config_entry_id( + self._entry_id + ) } @property @@ -205,12 +203,6 @@ def signal_device_missing(self) -> str: """Signal specific per nmap tracker entry to signal a missing device.""" return f"{DOMAIN}-device-missing-{self._entry_id}" - @callback - def _async_get_vendor(self, mac_address): - """Lookup the vendor.""" - oui = self._mac_vendor_lookup.sanitise(mac_address)[:6] - return self._mac_vendor_lookup.prefixes.get(oui) - @callback def _async_stop(self): """Stop the scanner.""" @@ -226,11 +218,8 @@ async def _async_start_scanner(self, *_): self._scan_interval, ) ) - self._mac_vendor_lookup = AsyncMacLookup() - with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)): - # We don't care if this fails since it only - # improves the data when we don't have it from nmap - await self._mac_vendor_lookup.load_vendors() + if not aiooui.is_loaded(): + await aiooui.async_load() self._hass.async_create_task(self._async_scan_devices()) def _build_options(self): @@ -292,7 +281,7 @@ async def _async_mark_missing_devices_as_not_home(self): None, original_name, None, - self._async_get_vendor(mac_address), + aiooui.get_vendor(mac_address), "Device not found in initial scan", now, 1, @@ -401,7 +390,7 @@ async def _async_run_nmap_scan(self): continue hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) + vendor = info.get("vendor", {}).get(mac) or aiooui.get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( formatted_mac, hostname, name, ipv4, vendor, reason, now, None diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index b9464020431e28..5200f778d4cb8f 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -7,9 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "iot_class": "local_polling", "loggers": ["nmap"], - "requirements": [ - "netmap==0.7.0.2", - "getmac==0.9.4", - "mac-vendor-lookup==0.1.12" - ] + "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.5"] } diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index e91b5cec92d523..8ab277c3def9dc 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -114,7 +114,7 @@ async def _update_no_ip( except aiohttp.ClientError: _LOGGER.warning("Can't connect to NO-IP API") - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain) return False diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 406acd6aabd734..3ed2c7bdb930fa 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -4,29 +4,20 @@ import asyncio from dataclasses import dataclass, field from datetime import timedelta -import logging -import traceback from typing import Any from uuid import UUID -from aionotion import async_get_client -from aionotion.bridge.models import Bridge, BridgeAllResponse +from aionotion.bridge.models import Bridge from aionotion.errors import InvalidCredentialsError, NotionError -from aionotion.sensor.models import ( - Listener, - ListenerAllResponse, - ListenerKind, - Sensor, - SensorAllResponse, -) -from aionotion.user.models import UserPreferences, UserPreferencesResponse +from aionotion.listener.models import Listener, ListenerKind +from aionotion.sensor.models import Sensor +from aionotion.user.models import UserPreferences from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( - aiohttp_client, config_validation as cv, device_registry as dr, entity_registry as er, @@ -40,6 +31,8 @@ ) from .const import ( + CONF_REFRESH_TOKEN, + CONF_USER_UUID, DOMAIN, LOGGER, SENSOR_BATTERY, @@ -53,6 +46,7 @@ SENSOR_TEMPERATURE, SENSOR_WINDOW_HINGED, ) +from .util import async_get_client_with_credentials, async_get_client_with_refresh_token PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -112,88 +106,118 @@ class NotionData: # Define a user preferences response object: user_preferences: UserPreferences | None = field(default=None) - def update_data_from_response( - self, - response: BridgeAllResponse - | ListenerAllResponse - | SensorAllResponse - | UserPreferencesResponse, - ) -> None: - """Update data from an aionotion response.""" - if isinstance(response, BridgeAllResponse): - for bridge in response.bridges: - # If a new bridge is discovered, register it: - if bridge.id not in self.bridges: - _async_register_new_bridge(self.hass, self.entry, bridge) - self.bridges[bridge.id] = bridge - elif isinstance(response, ListenerAllResponse): - self.listeners = {listener.id: listener for listener in response.listeners} - elif isinstance(response, SensorAllResponse): - self.sensors = {sensor.uuid: sensor for sensor in response.sensors} - elif isinstance(response, UserPreferencesResponse): - self.user_preferences = response.user_preferences + def update_bridges(self, bridges: list[Bridge]) -> None: + """Update the bridges.""" + for bridge in bridges: + # If a new bridge is discovered, register it: + if bridge.id not in self.bridges: + _async_register_new_bridge(self.hass, self.entry, bridge) + self.bridges[bridge.id] = bridge + + def update_listeners(self, listeners: list[Listener]) -> None: + """Update the listeners.""" + self.listeners = {listener.id: listener for listener in listeners} + + def update_sensors(self, sensors: list[Sensor]) -> None: + """Update the sensors.""" + self.sensors = {sensor.uuid: sensor for sensor in sensors} + + def update_user_preferences(self, user_preferences: UserPreferences) -> None: + """Update the user preferences.""" + self.user_preferences = user_preferences def asdict(self) -> dict[str, Any]: """Represent this dataclass (and its Pydantic contents) as a dict.""" data: dict[str, Any] = { - DATA_BRIDGES: [bridge.dict() for bridge in self.bridges.values()], - DATA_LISTENERS: [listener.dict() for listener in self.listeners.values()], - DATA_SENSORS: [sensor.dict() for sensor in self.sensors.values()], + DATA_BRIDGES: [item.to_dict() for item in self.bridges.values()], + DATA_LISTENERS: [item.to_dict() for item in self.listeners.values()], + DATA_SENSORS: [item.to_dict() for item in self.sensors.values()], } if self.user_preferences: - data[DATA_USER_PREFERENCES] = self.user_preferences.dict() + data[DATA_USER_PREFERENCES] = self.user_preferences.to_dict() return data async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" - if not entry.unique_id: - hass.config_entries.async_update_entry( - entry, unique_id=entry.data[CONF_USERNAME] - ) + entry_updates: dict[str, Any] = {"data": {**entry.data}} - session = aiohttp_client.async_get_clientsession(hass) + if not entry.unique_id: + entry_updates["unique_id"] = entry.data[CONF_USERNAME] try: - client = await async_get_client( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session - ) + if password := entry_updates["data"].pop(CONF_PASSWORD, None): + # If a password exists in the config entry data, use it to get a new client + # (and pop it from the new entry data): + client = await async_get_client_with_credentials( + hass, entry.data[CONF_USERNAME], password + ) + else: + # If a password doesn't exist in the config entry data, we can safely assume + # that a refresh token and user UUID do, so we use them to get the client: + client = await async_get_client_with_refresh_token( + hass, + entry.data[CONF_USER_UUID], + entry.data[CONF_REFRESH_TOKEN], + ) except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed("Invalid username and/or password") from err + raise ConfigEntryAuthFailed("Invalid credentials") from err except NotionError as err: raise ConfigEntryNotReady("Config entry failed to load") from err + # Always update the config entry with the latest refresh token and user UUID: + entry_updates["data"][CONF_REFRESH_TOKEN] = client.refresh_token + entry_updates["data"][CONF_USER_UUID] = client.user_uuid + + @callback + def async_save_refresh_token(refresh_token: str) -> None: + """Save a refresh token to the config entry data.""" + LOGGER.debug("Saving new refresh token to HASS storage") + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: refresh_token} + ) + + # Create a callback to save the refresh token when it changes: + entry.async_on_unload(client.add_refresh_token_callback(async_save_refresh_token)) + + hass.config_entries.async_update_entry(entry, **entry_updates) + async def async_update() -> NotionData: """Get the latest data from the Notion API.""" data = NotionData(hass=hass, entry=entry) - tasks = { - DATA_BRIDGES: client.bridge.async_all(), - DATA_LISTENERS: client.sensor.async_listeners(), - DATA_SENSORS: client.sensor.async_all(), - DATA_USER_PREFERENCES: client.user.async_preferences(), - } - results = await asyncio.gather(*tasks.values(), return_exceptions=True) - for attr, result in zip(tasks, results): + try: + async with asyncio.TaskGroup() as tg: + bridges = tg.create_task(client.bridge.async_all()) + listeners = tg.create_task(client.listener.async_all()) + sensors = tg.create_task(client.sensor.async_all()) + user_preferences = tg.create_task(client.user.async_preferences()) + except BaseExceptionGroup as err: + result = err.exceptions[0] if isinstance(result, InvalidCredentialsError): raise ConfigEntryAuthFailed( "Invalid username and/or password" ) from result if isinstance(result, NotionError): raise UpdateFailed( - f"There was a Notion error while updating {attr}: {result}" + f"There was a Notion error while updating: {result}" ) from result if isinstance(result, Exception): - if LOGGER.isEnabledFor(logging.DEBUG): - LOGGER.debug("".join(traceback.format_tb(result.__traceback__))) + LOGGER.debug( + "There was an unknown error while updating: %s", + result, + exc_info=result, + ) raise UpdateFailed( - f"There was an unknown error while updating {attr}: {result}" + f"There was an unknown error while updating: {result}" ) from result if isinstance(result, BaseException): raise result from None - data.update_data_from_response(result) # type: ignore[arg-type] - + data.update_bridges(bridges.result()) + data.update_listeners(listeners.result()) + data.update_sensors(sensors.result()) + data.update_user_preferences(user_preferences.result()) return data coordinator = DataUpdateCoordinator( @@ -232,7 +256,7 @@ def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None listener for listener in coordinator.data.listeners.values() if listener.sensor_id == sensor.uuid - and listener.listener_kind == TASK_TYPE_TO_LISTENER_MAP[task_type] + and listener.definition_id == TASK_TYPE_TO_LISTENER_MAP[task_type].value ) return {"new_unique_id": listener.id} diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 8e4d5927152cd9..dfa6dc5ec06e74 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Literal -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -123,7 +123,7 @@ async def async_setup_entry( ) for listener_id, listener in coordinator.data.listeners.items() for description in BINARY_SENSOR_DESCRIPTIONS - if description.listener_kind == listener.listener_kind + if description.listener_kind.value == listener.definition_id and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -138,6 +138,6 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" if not self.listener.insights.primary.value: - LOGGER.warning("Unknown listener structure: %s", self.listener.dict()) + LOGGER.warning("Unknown listener structure: %s", self.listener) return False return self.listener.insights.primary.value == self.entity_description.on_state diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 1e4adab2910c6d..f43c87b50859d6 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -2,9 +2,9 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass, field from typing import Any -from aionotion import async_get_client from aionotion.errors import InvalidCredentialsError, NotionError import voluptuous as vol @@ -13,9 +13,9 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, LOGGER +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN, LOGGER +from .util import async_get_client_with_credentials AUTH_SCHEMA = vol.Schema( { @@ -30,15 +30,23 @@ ) +@dataclass(frozen=True, kw_only=True) +class CredentialsValidationResult: + """Define a validation result.""" + + user_uuid: str | None = None + refresh_token: str | None = None + errors: dict[str, Any] = field(default_factory=dict) + + async def async_validate_credentials( hass: HomeAssistant, username: str, password: str -) -> dict[str, Any]: - """Validate a Notion username and password (returning any errors).""" - session = aiohttp_client.async_get_clientsession(hass) +) -> CredentialsValidationResult: + """Validate a Notion username and password.""" errors = {} try: - await async_get_client(username, password, session=session) + client = await async_get_client_with_credentials(hass, username, password) except InvalidCredentialsError: errors["base"] = "invalid_auth" except NotionError as err: @@ -48,7 +56,12 @@ async def async_validate_credentials( LOGGER.exception("Unknown error while validation credentials: %s", err) errors["base"] = "unknown" - return errors + if errors: + return CredentialsValidationResult(errors=errors) + + return CredentialsValidationResult( + user_uuid=client.user_uuid, refresh_token=client.refresh_token + ) class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -82,20 +95,24 @@ async def async_step_reauth_confirm( }, ) - if errors := await async_validate_credentials( + credentials_validation_result = await async_validate_credentials( self.hass, self._reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ): + ) + + if credentials_validation_result.errors: return self.async_show_form( step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, - errors=errors, + errors=credentials_validation_result.errors, description_placeholders={ CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] }, ) self.hass.config_entries.async_update_entry( - self._reauth_entry, data=self._reauth_entry.data | user_input + self._reauth_entry, + data=self._reauth_entry.data + | {CONF_REFRESH_TOKEN: credentials_validation_result.refresh_token}, ) self.hass.async_create_task( self.hass.config_entries.async_reload(self._reauth_entry.entry_id) @@ -112,13 +129,22 @@ async def async_step_user( await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() - if errors := await async_validate_credentials( + credentials_validation_result = await async_validate_credentials( self.hass, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] - ): + ) + + if credentials_validation_result.errors: return self.async_show_form( step_id="user", data_schema=AUTH_SCHEMA, - errors=errors, + errors=credentials_validation_result.errors, ) - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_USER_UUID: credentials_validation_result.user_uuid, + CONF_REFRESH_TOKEN: credentials_validation_result.refresh_token, + }, + ) diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 0961b7c10c5d4f..b1ea921a71bd6d 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -4,6 +4,9 @@ DOMAIN = "notion" LOGGER = logging.getLogger(__package__) +CONF_REFRESH_TOKEN = "refresh_token" +CONF_USER_UUID = "user_uuid" + SENSOR_BATTERY = "low_battery" SENSOR_DOOR = "door" SENSOR_GARAGE_DOOR = "garage_door" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 86b84760016f11..5c32f235639aab 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,12 +5,12 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.const import CONF_EMAIL, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NotionData -from .const import DOMAIN +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN CONF_DEVICE_KEY = "device_key" CONF_HARDWARE_ID = "hardware_id" @@ -23,12 +23,13 @@ CONF_EMAIL, CONF_HARDWARE_ID, CONF_LAST_BRIDGE_HARDWARE_ID, - CONF_PASSWORD, + CONF_REFRESH_TOKEN, # Config entry title and unique ID may contain sensitive data: CONF_TITLE, CONF_UNIQUE_ID, CONF_USERNAME, CONF_USER_ID, + CONF_USER_UUID, } diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index f23a082df35ef2..5fc94b5e6469e0 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aionotion"], - "requirements": ["aionotion==2023.05.5"] + "requirements": ["aionotion==2024.02.2"] } diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index a774bfdfad3e0b..059ea551b093ed 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -1,7 +1,7 @@ """Define Notion model mixins.""" from dataclasses import dataclass -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 1d2c81addfaaf5..f5439895ac96f2 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,7 +1,7 @@ """Support for Notion sensors.""" from dataclasses import dataclass -from aionotion.sensor.models import ListenerKind +from aionotion.listener.models import ListenerKind from homeassistant.components.sensor import ( SensorDeviceClass, @@ -59,7 +59,7 @@ async def async_setup_entry( ) for listener_id, listener in coordinator.data.listeners.items() for description in SENSOR_DESCRIPTIONS - if description.listener_kind == listener.listener_kind + if description.listener_kind.value == listener.definition_id and (sensor := coordinator.data.sensors[listener.sensor_id]) ] ) @@ -71,7 +71,7 @@ class NotionSensor(NotionEntity, SensorEntity): @property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor.""" - if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if self.listener.definition_id == ListenerKind.TEMPERATURE.value: if not self.coordinator.data.user_preferences: return None if self.coordinator.data.user_preferences.celsius_enabled: @@ -84,7 +84,7 @@ def native_value(self) -> str | None: """Return the value reported by the sensor.""" if not self.listener.status_localized: return None - if self.listener.listener_kind == ListenerKind.TEMPERATURE: + if self.listener.definition_id == ListenerKind.TEMPERATURE.value: # The Notion API only returns a localized string for temperature (e.g. # "70°"); we simply remove the degree symbol: return self.listener.status_localized.state[:-1] diff --git a/homeassistant/components/notion/util.py b/homeassistant/components/notion/util.py new file mode 100644 index 00000000000000..553199b7c7a031 --- /dev/null +++ b/homeassistant/components/notion/util.py @@ -0,0 +1,30 @@ +"""Define notion utilities.""" +from aionotion import ( + async_get_client_with_credentials as cwc, + async_get_client_with_refresh_token as cwrt, +) +from aionotion.client import Client + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.instance_id import async_get + + +async def async_get_client_with_credentials( + hass: HomeAssistant, email: str, password: str +) -> Client: + """Get a Notion client with credentials.""" + session = aiohttp_client.async_get_clientsession(hass) + instance_id = await async_get(hass) + return await cwc(email, password, session=session, session_name=instance_id) + + +async def async_get_client_with_refresh_token( + hass: HomeAssistant, user_uuid: str, refresh_token: str +) -> Client: + """Get a Notion client with credentials.""" + session = aiohttp_client.async_get_clientsession(hass) + instance_id = await async_get(hass) + return await cwrt( + user_uuid, refresh_token, session=session, session_name=instance_id + ) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 42d95f85937ace..0ea75590ee391d 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -209,6 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name=f"Nuki Bridge {bridge_id}", model="Hardware Bridge", sw_version=info["versions"]["firmwareVersion"], + serial_number=parse_id(info["ids"]["hardwareId"]), ) try: @@ -304,7 +305,7 @@ def bridge_id(self): async def _async_update_data(self) -> None: """Fetch data from Nuki bridge.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): events = await self.hass.async_add_executor_job( @@ -332,6 +333,7 @@ def update_devices(self, devices: list[NukiDevice]) -> dict[str, set[str]]: Returns: A dict with the events to be fired. The event type is the key and the device ids are the value + """ events: dict[str, set[str]] = defaultdict(set) @@ -383,4 +385,5 @@ def device_info(self) -> DeviceInfo: model=self._nuki_device.device_model_str.capitalize(), sw_version=self._nuki_device.firmware_version, via_device=(DOMAIN, self.coordinator.bridge_id), + serial_number=parse_id(self._nuki_device.nuki_id), ) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index e3b2d129017e05..c01c1c5023753f 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -9,6 +9,7 @@ BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,19 +23,19 @@ async def async_setup_entry( """Set up the Nuki binary sensors.""" entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] - lock_entities = [] - opener_entities = [] + entities: list[NukiEntity] = [] for lock in entry_data.locks: if lock.is_door_sensor_activated: - lock_entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) - - async_add_entities(lock_entities) + entities.append(NukiDoorsensorEntity(entry_data.coordinator, lock)) + entities.append(NukiBatteryCriticalEntity(entry_data.coordinator, lock)) + entities.append(NukiBatteryChargingEntity(entry_data.coordinator, lock)) for opener in entry_data.openers: - opener_entities.extend([NukiRingactionEntity(entry_data.coordinator, opener)]) + entities.append(NukiRingactionEntity(entry_data.coordinator, opener)) + entities.append(NukiBatteryCriticalEntity(entry_data.coordinator, opener)) - async_add_entities(opener_entities) + async_add_entities(entities) class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): @@ -83,7 +84,6 @@ class NukiRingactionEntity(NukiEntity[NukiDevice], BinarySensorEntity): _attr_has_entity_name = True _attr_translation_key = "ring_action" - _attr_icon = "mdi:bell-ring" @property def unique_id(self) -> str: @@ -102,3 +102,40 @@ def extra_state_attributes(self): def is_on(self) -> bool: """Return the value of the ring action state.""" return self._nuki_device.ring_action_state + + +class NukiBatteryCriticalEntity(NukiEntity[NukiDevice], BinarySensorEntity): + """Representation of Nuki Battery Critical.""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._nuki_device.nuki_id}_battery_critical" + + @property + def is_on(self) -> bool: + """Return the value of the battery critical.""" + return self._nuki_device.battery_critical + + +class NukiBatteryChargingEntity(NukiEntity[NukiDevice], BinarySensorEntity): + """Representation of a Nuki Battery charging.""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._nuki_device.nuki_id}_battery_charging" + + @property + def is_on(self) -> bool: + """Return the value of the battery charging.""" + return self._nuki_device.battery_charging diff --git a/homeassistant/components/nuki/icons.json b/homeassistant/components/nuki/icons.json new file mode 100644 index 00000000000000..f74603cb9dc14a --- /dev/null +++ b/homeassistant/components/nuki/icons.json @@ -0,0 +1,13 @@ +{ + "entity": { + "binary_sensor": { + "ring_action": { + "default": "mdi:bell-ring" + } + } + }, + "services": { + "lock_n_go": "mdi:lock-clock", + "set_continuous_mode": "mdi:bell-cog" + } +} diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index b84bee660c1b10..b2e039ec122c2c 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.2"] + "requirements": ["pynuki==1.6.3"] } diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index e4721d2d41c8f8..e8290f5fa6d073 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -984,7 +984,7 @@ async def async_setup_entry( if KEY_STATUS in resources: resources.append(KEY_STATUS_DISPLAY) - entities = [ + async_add_entities( NUTSensor( coordinator, SENSOR_TYPES[sensor_type], @@ -992,9 +992,7 @@ async def async_setup_entry( unique_id, ) for sensor_type in resources - ] - - async_add_entities(entities, True) + ) class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity): diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index a3e16fbad76454..0ba0b3dfc5edeb 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -36,9 +36,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac ) - hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac)) - - entry.version = 2 + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(device_mac), version=2 + ) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 50ba6c964f3a4f..1a96078c003a9b 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import aiohttp from pyoctoprintapi import OctoprintClient @@ -11,24 +12,28 @@ from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, + CONF_DEVICE_ID, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PATH, CONF_PORT, + CONF_PROFILE_NAME, CONF_SENSORS, CONF_SSL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context -from .const import DOMAIN +from .const import CONF_BAUDRATE, DOMAIN, SERVICE_CONNECT from .coordinator import OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -122,6 +127,15 @@ def ensure_valid_path(value): extra=vol.ALLOW_EXTRA, ) +SERVICE_CONNECT_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Optional(CONF_PROFILE_NAME): cv.string, + vol.Optional(CONF_PORT): cv.string, + vol.Optional(CONF_BAUDRATE): cv.positive_int, + } +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the OctoPrint component.""" @@ -194,6 +208,23 @@ def _async_close_websession(event: Event) -> None: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async def async_printer_connect(call: ServiceCall) -> None: + """Connect to a printer.""" + client = async_get_client_for_service_call(hass, call) + await client.connect( + printer_profile=call.data.get(CONF_PROFILE_NAME), + port=call.data.get(CONF_PORT), + baud_rate=call.data.get(CONF_BAUDRATE), + ) + + if not hass.services.has_service(DOMAIN, SERVICE_CONNECT): + hass.services.async_register( + DOMAIN, + SERVICE_CONNECT, + async_printer_connect, + schema=SERVICE_CONNECT_SCHEMA, + ) + return True @@ -205,3 +236,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +def async_get_client_for_service_call( + hass: HomeAssistant, call: ServiceCall +) -> OctoprintClient: + """Get the client related to a service call (by device ID).""" + device_id = call.data[CONF_DEVICE_ID] + device_registry = dr.async_get(hass) + + if device_entry := device_registry.async_get(device_id): + for entry_id in device_entry.config_entries: + if data := hass.data[DOMAIN].get(entry_id): + return cast(OctoprintClient, data["client"]) + + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_client", + translation_placeholders={ + "device_id": device_id, + }, + ) diff --git a/homeassistant/components/octoprint/const.py b/homeassistant/components/octoprint/const.py index df22cb8d8f8042..2d2a9e4a907368 100644 --- a/homeassistant/components/octoprint/const.py +++ b/homeassistant/components/octoprint/const.py @@ -3,3 +3,6 @@ DOMAIN = "octoprint" DEFAULT_NAME = "OctoPrint" + +SERVICE_CONNECT = "printer_connect" +CONF_BAUDRATE = "baudrate" diff --git a/homeassistant/components/octoprint/services.yaml b/homeassistant/components/octoprint/services.yaml new file mode 100644 index 00000000000000..2cb4a6f3c2dac4 --- /dev/null +++ b/homeassistant/components/octoprint/services.yaml @@ -0,0 +1,27 @@ +printer_connect: + fields: + device_id: + required: true + selector: + device: + integration: octoprint + profile_name: + required: false + selector: + text: + port: + required: false + selector: + text: + baudrate: + required: false + selector: + select: + options: + - "9600" + - "19200" + - "38400" + - "57600" + - "115200" + - "230400" + - "250000" diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 63d9753ee1d8c2..e9df0ed755cecb 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -35,5 +35,34 @@ "progress": { "get_api_key": "Open the OctoPrint UI and click 'Allow' on the Access Request for 'Home Assistant'." } + }, + "exceptions": { + "missing_client": { + "message": "No client for device ID: {device_id}" + } + }, + "services": { + "printer_connect": { + "name": "Connect to a printer", + "description": "Instructs the octoprint server to connect to a printer.", + "fields": { + "device_id": { + "name": "Server", + "description": "The server that should connect." + }, + "profile_name": { + "name": "Profile name", + "description": "Printer profile to connect with." + }, + "port": { + "name": "Serial port", + "description": "Port name to connect on." + }, + "baudrate": { + "name": "Baudrate", + "description": "Baud rate." + } + } + } } } diff --git a/homeassistant/components/oncue/const.py b/homeassistant/components/oncue/const.py index 7118944a4ec457..599ef5ee22bd0c 100644 --- a/homeassistant/components/oncue/const.py +++ b/homeassistant/components/oncue/const.py @@ -1,6 +1,5 @@ """Constants for the Oncue integration.""" -import asyncio import aiohttp from aiooncue import ServiceFailedException @@ -8,7 +7,7 @@ DOMAIN = "oncue" CONNECTION_EXCEPTIONS = ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, ServiceFailedException, ) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 2840cde704bc1e..e7e30588f8a086 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -38,7 +38,8 @@ class OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"sensed_{id.lower()}", + translation_key="sensed_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ), @@ -47,7 +48,8 @@ class OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"sensed_{id}", + translation_key="sensed_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_7 ), @@ -56,7 +58,8 @@ class OneWireBinarySensorEntityDescription( key=f"sensed.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"sensed_{id.lower()}", + translation_key="sensed_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ), @@ -72,7 +75,8 @@ class OneWireBinarySensorEntityDescription( read_mode=READ_MODE_BOOL, entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, - translation_key=f"hub_short_{id}", + translation_key="hub_short_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_3 ), diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index cc8b14b5d6ed6a..a7d199c21a9321 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -236,7 +236,8 @@ def _get_sensor_precision_family_28(device_id: str, options: Mapping[str, Any]) native_unit_of_measurement="count", read_mode=READ_MODE_INT, state_class=SensorStateClass.TOTAL_INCREASING, - translation_key=f"counter_{id.lower()}", + translation_key="counter_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ), @@ -276,7 +277,8 @@ def _get_sensor_precision_family_28(device_id: str, options: Mapping[str, Any]) native_unit_of_measurement=UnitOfPressure.CBAR, read_mode=READ_MODE_FLOAT, state_class=SensorStateClass.MEASUREMENT, - translation_key=f"moisture_{id}", + translation_key="moisture_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_3 ), @@ -396,7 +398,8 @@ def get_entities( description, device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - translation_key=f"wetness_{s_id}", + translation_key="wetness_id", + translation_placeholders={"id": s_id}, ) override_key = None if description.override_key: diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 753f244cfe9d11..8dbcbdf8978f26 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -21,55 +21,16 @@ }, "entity": { "binary_sensor": { - "sensed_a": { - "name": "Sensed A" + "sensed_id": { + "name": "Sensed {id}" }, - "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" + "hub_short_id": { + "name": "Hub short on branch {id}" } }, "sensor": { - "counter_a": { - "name": "Counter A" - }, - "counter_b": { - "name": "Counter B" + "counter_id": { + "name": "Counter {id}" }, "humidity_hih3600": { "name": "HIH3600 humidity" @@ -86,17 +47,8 @@ "humidity_raw": { "name": "Raw humidity" }, - "moisture_1": { - "name": "Moisture 1" - }, - "moisture_2": { - "name": "Moisture 2" - }, - "moisture_3": { - "name": "Moisture 3" - }, - "moisture_4": { - "name": "Moisture 4" + "moisture_id": { + "name": "Moisture {id}" }, "thermocouple_temperature_k": { "name": "Thermocouple K temperature" @@ -113,121 +65,31 @@ "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" + "wetness_id": { + "name": "Wetness {id}" } }, "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" + "hub_branch_id": { + "name": "Hub branch {id}" }, "iad": { "name": "Current A/D control" }, - "latch_0": { - "name": "Latch 0" - }, - "latch_1": { - "name": "Latch 1" + "latch_id": { + "name": "Latch {id}" }, - "latch_2": { - "name": "Latch 2" + "leaf_sensor_id": { + "name": "Leaf sensor {id}" }, - "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" + "moisture_sensor_id": { + "name": "Moisture sensor {id}" }, "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" + "pio_id": { + "name": "Programmed input-output {id}" } } }, diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index db9e8f5b0f8d35..00a3f8f65f436e 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -42,7 +42,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr key=f"PIO.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"pio_{id.lower()}", + translation_key="pio_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ] @@ -51,7 +52,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr key=f"latch.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"latch_{id.lower()}", + translation_key="latch_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ] @@ -71,7 +73,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr key=f"PIO.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"pio_{id}", + translation_key="pio_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_7 ] @@ -80,7 +83,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr key=f"latch.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"latch_{id}", + translation_key="latch_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_7 ] @@ -90,7 +94,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr key=f"PIO.{id}", entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, - translation_key=f"pio_{id.lower()}", + translation_key="pio_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_A_B ), @@ -106,7 +111,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, - translation_key=f"hub_branch_{id}", + translation_key="hub_branch_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_3 ), @@ -117,7 +123,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, - translation_key=f"leaf_sensor_{id}", + translation_key="leaf_sensor_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_3 ] @@ -127,7 +134,8 @@ class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescr entity_registry_enabled_default=False, read_mode=READ_MODE_BOOL, entity_category=EntityCategory.CONFIG, - translation_key=f"moisture_sensor_{id}", + translation_key="moisture_sensor_id", + translation_placeholders={"id": str(id)}, ) for id in DEVICE_KEYS_0_3 ] diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 013dd2e453f22e..c6ee74c2c50e28 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -197,7 +197,7 @@ async def _async_get_stream_uri(self) -> str: self._stream_uri_future = loop.create_future() try: uri_no_auth = await self.device.async_get_stream_uri(self.profile) - except (asyncio.TimeoutError, Exception) as err: + except (TimeoutError, Exception) as err: LOGGER.error("Failed to get stream uri: %s", err) if self._stream_uri_future: self._stream_uri_future.set_exception(err) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 603957a230e2ac..c5539818a1c74e 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -32,7 +32,7 @@ # entities for them. UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"} -SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError) +SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError) CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError) SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError) UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS) diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py index 64b46a1da948ac..0dbebda69628b9 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -209,7 +209,7 @@ async def async_process_image(self, image): _LOGGER.error("Error %d -> %s", request.status, data.get("error")) return - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for OpenALPR API") return diff --git a/homeassistant/components/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py index b78227ed1e5122..0425b44d9e657d 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -81,7 +81,7 @@ async def async_step_user( errors["base"] = "invalid_auth" except OpenExchangeRatesClientError: errors["base"] = "cannot_connect" - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "timeout_connect" except Exception: # pylint: disable=broad-except LOGGER.exception("Unexpected exception") @@ -126,6 +126,6 @@ async def async_get_currencies(self) -> dict[str, str]: self.currencies = await client.get_currencies() except OpenExchangeRatesClientError as err: raise AbortFlow("cannot_connect") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise AbortFlow("timeout_connect") from err return self.currencies diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index c7ee5a7d00c728..fb03ab214f3c8b 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -1,6 +1,5 @@ """The openhome component.""" -import asyncio import logging import aiohttp @@ -43,7 +42,7 @@ async def async_setup_entry( try: await device.init() - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as exc: + except (TimeoutError, aiohttp.ClientError, UpnpError) as exc: raise ConfigEntryNotReady from exc _LOGGER.debug("Initialised device: %s", device.uuid()) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 4935af1bc4614d..25052824ffecba 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -1,7 +1,6 @@ """Support for Openhome Devices.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine import functools import logging @@ -76,7 +75,7 @@ def catch_request_errors() -> ( [_FuncType[_OpenhomeDeviceT, _P, _R]], _ReturnFuncType[_OpenhomeDeviceT, _P, _R] ] ): - """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" + """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( func: _FuncType[_OpenhomeDeviceT, _P, _R], @@ -87,10 +86,10 @@ def call_wrapper( async def wrapper( self: _OpenhomeDeviceT, *args: _P.args, **kwargs: _P.kwargs ) -> _R | None: - """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" + """Catch TimeoutError, aiohttp.ClientError, UpnpError errors.""" try: return await func(self, *args, **kwargs) - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): + except (TimeoutError, aiohttp.ClientError, UpnpError): _LOGGER.error("Error during call %s", func.__name__) return None @@ -186,7 +185,7 @@ async def async_update(self) -> None: self._attr_state = MediaPlayerState.PLAYING self._attr_available = True - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError): + except (TimeoutError, aiohttp.ClientError, UpnpError): self._attr_available = False @catch_request_errors() diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 691776e4dfd6cc..6d36bccec65d6e 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -1,7 +1,6 @@ """Update entities for Linn devices.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -93,7 +92,7 @@ async def async_install( try: if self.latest_version: await self._device.update_firmware() - except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError) as err: + except (TimeoutError, aiohttp.ClientError, UpnpError) as err: raise HomeAssistantError( f"Error updating {self._device.device.friendly_name}: {err}" ) from err diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index cd8b98880d55dc..12f4724e05603d 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -114,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: async with asyncio.timeout(CONNECTION_TIMEOUT): await gateway.connect_and_subscribe() - except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: + except (TimeoutError, ConnectionError, SerialException) as ex: await gateway.cleanup() raise ConfigEntryNotReady( f"Could not connect to gateway at {gateway.device_path}: {ex}" diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 07187f3a2ecf13..70bed0d1665ce2 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -70,7 +70,7 @@ async def test_connection(): try: async with asyncio.timeout(CONNECTION_TIMEOUT): await test_connection() - except asyncio.TimeoutError: + except TimeoutError: return self._show_form({"base": "timeout_connect"}) except (ConnectionError, SerialException): return self._show_form({"base": "cannot_connect"}) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 048ffdd237b19f..a9ea1946f91fa9 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -104,8 +104,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if version == 1: data.pop(CONF_BINARY_SENSORS, None) data.pop(CONF_SENSORS, None) - version = entry.version = 2 - hass.config_entries.async_update_entry(entry, data=data) + version = 2 + hass.config_entries.async_update_entry(entry, data=data, version=2) LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index cfe28e2eacc60b..22c97d72fa5611 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -78,10 +78,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: mode = FORECAST_MODE_ONECALL_DAILY new_data = {**data, CONF_MODE: mode} - version = entry.version = CONFIG_FLOW_VERSION - config_entries.async_update_entry(entry, data=new_data) + config_entries.async_update_entry( + entry, data=new_data, version=CONFIG_FLOW_VERSION + ) - _LOGGER.info("Migration to version %s successful", version) + _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) return True diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 527856ed56e342..78a8315335c09e 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -32,9 +32,7 @@ def _get_mac_addrs(self, devices): """Create dict with mac address keys from list of devices.""" out_devices = {} for device in devices: - if not self.interfaces: - out_devices[device["mac"]] = device - elif device["intf_description"] in self.interfaces: + if not self.interfaces or device["intf_description"] in self.interfaces: out_devices[device["mac"]] = device return out_devices diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 418f2a5723b3e3..820aac5d20a263 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["recorder"], "documentation": "https://www.home-assistant.io/integrations/opower", + "import_executor": true, "iot_class": "cloud_polling", "loggers": ["opower"], "requirements": ["opower==0.3.1"] diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index c7cdaddf382654..f743122d0cbed7 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -38,23 +38,31 @@ ), OralBSensor.SECTOR: SensorEntityDescription( key=OralBSensor.SECTOR, + translation_key="sector", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.NUMBER_OF_SECTORS: SensorEntityDescription( key=OralBSensor.NUMBER_OF_SECTORS, + translation_key="number_of_sectors", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SECTOR_TIMER: SensorEntityDescription( key=OralBSensor.SECTOR_TIMER, + translation_key="sector_timer", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), OralBSensor.TOOTHBRUSH_STATE: SensorEntityDescription( - key=OralBSensor.TOOTHBRUSH_STATE + key=OralBSensor.TOOTHBRUSH_STATE, + name=None, + ), + OralBSensor.PRESSURE: SensorEntityDescription( + key=OralBSensor.PRESSURE, + translation_key="pressure", ), - OralBSensor.PRESSURE: SensorEntityDescription(key=OralBSensor.PRESSURE), OralBSensor.MODE: SensorEntityDescription( key=OralBSensor.MODE, + translation_key="mode", entity_category=EntityCategory.DIAGNOSTIC, ), OralBSensor.SIGNAL_STRENGTH: SensorEntityDescription( @@ -94,10 +102,7 @@ def sensor_update_to_bluetooth_data_update( device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value for device_key, sensor_values in sensor_update.entity_values.items() }, - entity_names={ - device_key_to_bluetooth_entity_key(device_key): sensor_values.name - for device_key, sensor_values in sensor_update.entity_values.items() - }, + entity_names={}, ) diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index d1d544c2381a3e..f60fd56a9a4659 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -18,5 +18,24 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "sector": { + "name": "Sector" + }, + "number_of_sectors": { + "name": "Number of sectors" + }, + "sector_timer": { + "name": "Sector timer" + }, + "pressure": { + "name": "Pressure" + }, + "mode": { + "name": "Brushing mode" + } + } } } diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 3c08a74ed61280..fe4cc8c1145a01 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -1,8 +1,6 @@ """The Open Thread Border Router integration.""" from __future__ import annotations -import asyncio - import aiohttp import python_otbr_api @@ -42,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ( HomeAssistantError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ) as err: raise ConfigEntryNotReady("Unable to connect") from err if border_agent_id is None: diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index b96e276af8bca4..d0d3f1c1060153 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -1,7 +1,6 @@ """Config flow for the Open Thread Border Router integration.""" from __future__ import annotations -import asyncio from contextlib import suppress import logging from typing import cast @@ -115,7 +114,7 @@ async def async_step_user( except ( python_otbr_api.OTBRError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" else: @@ -145,13 +144,14 @@ async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResu for current_entry in current_entries: if current_entry.source != SOURCE_HASSIO: continue - if current_entry.unique_id != discovery_info.uuid: - self.hass.config_entries.async_update_entry( - current_entry, unique_id=discovery_info.uuid - ) current_url = yarl.URL(current_entry.data["url"]) if ( - current_url.host != config["host"] + # The first version did not set a unique_id + # so if the entry does not have a unique_id + # we have to assume it's the first version + current_entry.unique_id + and (current_entry.unique_id != discovery_info.uuid) + or current_url.host != config["host"] or current_url.port == config["port"] ): continue diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py index 9a462c4610bda0..bd7eb9975580df 100644 --- a/homeassistant/components/otbr/silabs_multiprotocol.py +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging import aiohttp @@ -64,7 +63,7 @@ async def async_get_channel(hass: HomeAssistant) -> int | None: except ( HomeAssistantError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ) as err: _LOGGER.warning("Failed to communicate with OTBR %s", err) return None diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index ebb928e72d098d..472313aa315000 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -1,8 +1,6 @@ """The OurGroceries integration.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError - from aiohttp import ClientError from ourgroceries import OurGroceries from ourgroceries.exceptions import InvalidLoginException @@ -26,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) try: await og.login() - except (AsyncIOTimeoutError, ClientError) as error: + except (TimeoutError, ClientError) as error: raise ConfigEntryNotReady from error except InvalidLoginException: return False diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py index a982325fceb6e5..65670dd7f9284b 100644 --- a/homeassistant/components/ourgroceries/config_flow.py +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -1,7 +1,6 @@ """Config flow for OurGroceries integration.""" from __future__ import annotations -from asyncio import TimeoutError as AsyncIOTimeoutError import logging from typing import Any @@ -40,7 +39,7 @@ async def async_step_user( og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) try: await og.login() - except (AsyncIOTimeoutError, ClientError): + except (TimeoutError, ClientError): errors["base"] = "cannot_connect" except InvalidLoginException: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py index b6d31a8e6857ef..2c24ca4f832880 100644 --- a/homeassistant/components/overkiz/climate.py +++ b/homeassistant/components/overkiz/climate.py @@ -1,6 +1,8 @@ """Support for Overkiz climate devices.""" from __future__ import annotations +from typing import cast + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -8,8 +10,10 @@ from . import HomeAssistantOverkizData from .climate_entities import ( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY, WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, WIDGET_TO_CLIMATE_ENTITY, + Controllable, ) from .const import DOMAIN @@ -28,6 +32,18 @@ async def async_setup_entry( if device.widget in WIDGET_TO_CLIMATE_ENTITY ) + # Match devices based on the widget and controllableName + # This is for example used for Atlantic APC, where devices with different functionality share the same uiClass and widget. + async_add_entities( + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ + cast(Controllable, device.controllable_name) + ](device.device_url, data.coordinator) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY + and device.controllable_name + in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] + ) + # Hitachi Air To Air Heat Pumps async_add_entities( WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index c74ff2829cccc5..72230c99a05817 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -1,4 +1,6 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from enum import StrEnum, unique + from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget @@ -10,18 +12,31 @@ from .atlantic_heat_recovery_ventilation import AtlanticHeatRecoveryVentilation from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .atlantic_pass_apc_zone_control_zone import AtlanticPassAPCZoneControlZone from .hitachi_air_to_air_heat_pump_hlrrwifi import HitachiAirToAirHeatPumpHLRRWIFI +from .hitachi_air_to_air_heat_pump_ovp import HitachiAirToAirHeatPumpOVP from .somfy_heating_temperature_interface import SomfyHeatingTemperatureInterface from .somfy_thermostat import SomfyThermostat from .valve_heating_temperature_interface import ValveHeatingTemperatureInterface + +@unique +class Controllable(StrEnum): + """Enum for widget controllables.""" + + IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE = ( + "io:AtlanticPassAPCHeatingAndCoolingZoneComponent" + ) + IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE = ( + "io:AtlanticPassAPCZoneControlZoneComponent" + ) + + WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, UIWidget.ATLANTIC_ELECTRICAL_HEATER_WITH_ADJUSTABLE_TEMPERATURE_SETPOINT: AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: AtlanticHeatRecoveryVentilation, - # ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE works exactly the same as ATLANTIC_PASS_APC_HEATING_ZONE - UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: AtlanticPassAPCHeatingZone, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_HEATING_TEMPERATURE_INTERFACE: SomfyHeatingTemperatureInterface, @@ -29,9 +44,19 @@ UIWidget.VALVE_HEATING_TEMPERATURE_INTERFACE: ValveHeatingTemperatureInterface, } +# For Atlantic APC, some devices are standalone and control themselves, some others needs to be +# managed by a ZoneControl device. Widget name is the same in the two cases. +WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY = { + UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: { + Controllable.IO_ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: AtlanticPassAPCHeatingZone, + Controllable.IO_ATLANTIC_PASS_APC_ZONE_CONTROL_ZONE: AtlanticPassAPCZoneControlZone, + } +} + # Hitachi air-to-air heatpumps come in 2 flavors (HLRRWIFI and OVP) that are separated in 2 classes WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { UIWidget.HITACHI_AIR_TO_AIR_HEAT_PUMP: { Protocol.HLRR_WIFI: HitachiAirToAirHeatPumpHLRRWIFI, + Protocol.OVP: HitachiAirToAirHeatPumpOVP, }, } 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 25dab7c1d7e0db..157ec72a2496ef 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 @@ -49,7 +49,15 @@ OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_HOME, } -PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} +PRESET_MODES_TO_OVERKIZ: dict[str, str] = { + PRESET_COMFORT: OverkizCommandParam.COMFORT, + PRESET_AWAY: OverkizCommandParam.ABSENCE, + PRESET_ECO: OverkizCommandParam.ECO, + PRESET_FROST_PROTECTION: OverkizCommandParam.FROSTPROTECTION, + PRESET_EXTERNAL: OverkizCommandParam.EXTERNAL_SCHEDULING, + PRESET_HOME: OverkizCommandParam.INTERNAL_SCHEDULING, +} + OVERKIZ_TO_PROFILE_MODES: dict[str, str] = { OverkizCommandParam.OFF: PRESET_SLEEP, diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py index fe9f20b05fc02c..cfb92067875920 100644 --- a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -10,6 +10,7 @@ ) from homeassistant.const import UnitOfTemperature +from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { @@ -25,16 +26,48 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): """Representation of Atlantic Pass APC Zone Control.""" - _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) _enable_turn_on_off_backwards_compatibility = False + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + self._attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + + # Cooling is supported by a separate command + if self.is_auto_hvac_mode_available: + self._attr_hvac_modes.append(HVACMode.AUTO) + + @property + def is_auto_hvac_mode_available(self) -> bool: + """Check if auto mode is available on the ZoneControl.""" + + return self.executor.has_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH + ) and self.executor.has_state(OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH) + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" + + if ( + self.is_auto_hvac_mode_available + and cast( + str, + self.executor.select_state( + OverkizState.CORE_HEATING_COOLING_AUTO_SWITCH + ), + ) + == OverkizCommandParam.ON + ): + return HVACMode.AUTO + return OVERKIZ_TO_HVAC_MODE[ cast( str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) @@ -43,6 +76,18 @@ def hvac_mode(self) -> HVACMode: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" + + if self.is_auto_hvac_mode_available: + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_COOLING_AUTO_SWITCH, + OverkizCommandParam.ON + if hvac_mode == HVACMode.AUTO + else OverkizCommandParam.OFF, + ) + + if hvac_mode == HVACMode.AUTO: + return + await self.executor.async_execute_command( OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py new file mode 100644 index 00000000000000..a30cb93f287f62 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py @@ -0,0 +1,252 @@ +"""Support for Atlantic Pass APC Heating Control.""" +from __future__ import annotations + +from asyncio import sleep +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import PRESET_NONE, HVACMode +from homeassistant.const import ATTR_TEMPERATURE + +from ..coordinator import OverkizDataUpdateCoordinator +from .atlantic_pass_apc_heating_zone import AtlanticPassAPCHeatingZone +from .atlantic_pass_apc_zone_control import OVERKIZ_TO_HVAC_MODE + +PRESET_SCHEDULE = "schedule" +PRESET_MANUAL = "manual" + +OVERKIZ_MODE_TO_PRESET_MODES: dict[str, str] = { + OverkizCommandParam.MANU: PRESET_MANUAL, + OverkizCommandParam.INTERNAL_SCHEDULING: PRESET_SCHEDULE, +} + +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_MODE_TO_PRESET_MODES.items()} + +TEMPERATURE_ZONECONTROL_DEVICE_INDEX = 1 + + +# Those device depends on a main probe that choose the operating mode (heating, cooling, ...) +class AtlanticPassAPCZoneControlZone(AtlanticPassAPCHeatingZone): + """Representation of Atlantic Pass APC Heating And Cooling Zone Control.""" + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + + # There is less supported functions, because they depend on the ZoneControl. + if not self.is_using_derogated_temperature_fallback: + # Modes are not configurable, they will follow current HVAC Mode of Zone Control. + self._attr_hvac_modes = [] + + # Those are available and tested presets on Shogun. + self._attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + + # Those APC Heating and Cooling probes depends on the zone control device (main probe). + # Only the base device (#1) can be used to get/set some states. + # Like to retrieve and set the current operating mode (heating, cooling, drying, off). + self.zone_control_device = self.executor.linked_device( + TEMPERATURE_ZONECONTROL_DEVICE_INDEX + ) + + @property + def is_using_derogated_temperature_fallback(self) -> bool: + """Check if the device behave like the Pass APC Heating Zone.""" + + return self.executor.has_command( + OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE + ) + + @property + def zone_control_hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if ( + state := self.zone_control_device.states[ + OverkizState.IO_PASS_APC_OPERATING_MODE + ] + ) is not None and (value := state.value_as_str) is not None: + return OVERKIZ_TO_HVAC_MODE[value] + return HVACMode.OFF + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool, dry, off mode.""" + + if self.is_using_derogated_temperature_fallback: + return super().hvac_mode + + zone_control_hvac_mode = self.zone_control_hvac_mode + + # Should be same, because either thermostat or this integration change both. + on_off_state = cast( + str, + self.executor.select_state( + OverkizState.CORE_COOLING_ON_OFF + if zone_control_hvac_mode == HVACMode.COOL + else OverkizState.CORE_HEATING_ON_OFF + ), + ) + + # Device is Stopped, it means the air flux is flowing but its venting door is closed. + if on_off_state == OverkizCommandParam.OFF: + hvac_mode = HVACMode.OFF + else: + hvac_mode = zone_control_hvac_mode + + # It helps keep it consistent with the Zone Control, within the interface. + if self._attr_hvac_modes != [zone_control_hvac_mode, HVACMode.OFF]: + self._attr_hvac_modes = [zone_control_hvac_mode, HVACMode.OFF] + self.async_write_ha_state() + + return hvac_mode + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_hvac_mode(hvac_mode) + + # They are mainly managed by the Zone Control device + # However, it make sense to map the OFF Mode to the Overkiz STOP Preset + + if hvac_mode == HVACMode.OFF: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.OFF, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.OFF, + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_ON_OFF, + OverkizCommandParam.ON, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_ON_OFF, + OverkizCommandParam.ON, + ) + + await self.async_refresh_modes() + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., schedule, manual.""" + + if self.is_using_derogated_temperature_fallback: + return super().preset_mode + + mode = OVERKIZ_MODE_TO_PRESET_MODES[ + cast( + str, + self.executor.select_state( + OverkizState.IO_PASS_APC_COOLING_MODE + if self.zone_control_hvac_mode == HVACMode.COOL + else OverkizState.IO_PASS_APC_HEATING_MODE + ), + ) + ] + + return mode if mode is not None else PRESET_NONE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_preset_mode(preset_mode) + + mode = PRESET_MODES_TO_OVERKIZ[preset_mode] + + # For consistency, it is better both are synced like on the Thermostat. + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_HEATING_MODE, mode + ) + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_COOLING_MODE, mode + ) + + await self.async_refresh_modes() + + @property + def target_temperature(self) -> float: + """Return hvac target temperature.""" + + if self.is_using_derogated_temperature_fallback: + return super().target_temperature + + if self.zone_control_hvac_mode == HVACMode.COOL: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_COOLING_TARGET_TEMPERATURE + ), + ) + + if self.zone_control_hvac_mode == HVACMode.HEAT: + return cast( + float, + self.executor.select_state( + OverkizState.CORE_HEATING_TARGET_TEMPERATURE + ), + ) + + return cast( + float, self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + + if self.is_using_derogated_temperature_fallback: + return await super().async_set_temperature(**kwargs) + + temperature = kwargs[ATTR_TEMPERATURE] + + # Change both (heating/cooling) temperature is a good way to have consistency + await self.executor.async_execute_command( + OverkizCommand.SET_HEATING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_COOLING_TARGET_TEMPERATURE, + temperature, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION_ON_OFF_STATE, + OverkizCommandParam.OFF, + ) + + # Target temperature may take up to 1 minute to get refreshed. + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + ) + + async def async_refresh_modes(self) -> None: + """Refresh the device modes to have new states.""" + + # The device needs a bit of time to update everything before a refresh. + await sleep(2) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_HEATING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_MODE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_PASS_APC_COOLING_PROFILE + ) + + await self.executor.async_execute_command( + OverkizCommand.REFRESH_TARGET_TEMPERATURE + ) diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py new file mode 100644 index 00000000000000..bf6bb5f95d5aea --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py @@ -0,0 +1,357 @@ +"""Support for HitachiAirToAirHeatPump.""" +from __future__ import annotations + +from typing import Any + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRESET_NONE, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature + +from ..const import DOMAIN +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_HOLIDAY_MODE = "holiday_mode" +FAN_SILENT = "silent" +TEMP_MIN = 16 +TEMP_MAX = 32 +TEMP_AUTO_MIN = 22 +TEMP_AUTO_MAX = 28 +AUTO_PIVOT_TEMPERATURE = 25 +AUTO_TEMPERATURE_CHANGE_MIN = TEMP_AUTO_MIN - AUTO_PIVOT_TEMPERATURE +AUTO_TEMPERATURE_CHANGE_MAX = TEMP_AUTO_MAX - AUTO_PIVOT_TEMPERATURE + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + OverkizCommandParam.AUTOHEATING: HVACMode.AUTO, + OverkizCommandParam.AUTOCOOLING: HVACMode.AUTO, + OverkizCommandParam.ON: HVACMode.HEAT, + OverkizCommandParam.OFF: HVACMode.OFF, + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.FAN: HVACMode.FAN_ONLY, + OverkizCommandParam.DEHUMIDIFY: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, +} + +HVAC_MODES_TO_OVERKIZ: dict[HVACMode, str] = { + HVACMode.AUTO: OverkizCommandParam.AUTO, + HVACMode.HEAT: OverkizCommandParam.HEATING, + HVACMode.OFF: OverkizCommandParam.HEATING, + HVACMode.FAN_ONLY: OverkizCommandParam.FAN, + HVACMode.DRY: OverkizCommandParam.DEHUMIDIFY, + HVACMode.COOL: OverkizCommandParam.COOLING, +} + +OVERKIZ_TO_SWING_MODES: dict[str, str] = { + OverkizCommandParam.BOTH: SWING_BOTH, + OverkizCommandParam.HORIZONTAL: SWING_HORIZONTAL, + OverkizCommandParam.STOP: SWING_OFF, + OverkizCommandParam.VERTICAL: SWING_VERTICAL, +} + +SWING_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_SWING_MODES.items()} + +OVERKIZ_TO_FAN_MODES: dict[str, str] = { + OverkizCommandParam.AUTO: FAN_AUTO, + OverkizCommandParam.HIGH: FAN_HIGH, # fallback, state can be exposed as HIGH, new state = hi + OverkizCommandParam.HI: FAN_HIGH, + OverkizCommandParam.LOW: FAN_LOW, + OverkizCommandParam.LO: FAN_LOW, + OverkizCommandParam.MEDIUM: FAN_MEDIUM, # fallback, state can be exposed as MEDIUM, new state = med + OverkizCommandParam.MED: FAN_MEDIUM, + OverkizCommandParam.SILENT: OverkizCommandParam.SILENT, +} + +FAN_MODES_TO_OVERKIZ: dict[str, str] = { + FAN_AUTO: OverkizCommandParam.AUTO, + FAN_HIGH: OverkizCommandParam.HI, + FAN_LOW: OverkizCommandParam.LO, + FAN_MEDIUM: OverkizCommandParam.MED, + FAN_SILENT: OverkizCommandParam.SILENT, +} + + +class HitachiAirToAirHeatPumpOVP(OverkizEntity, ClimateEntity): + """Representation of Hitachi Air To Air HeatPump.""" + + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_fan_modes = [*FAN_MODES_TO_OVERKIZ] + _attr_preset_modes = [PRESET_NONE, PRESET_HOLIDAY_MODE] + _attr_swing_modes = [*SWING_MODES_TO_OVERKIZ] + _attr_target_temperature_step = 1.0 + _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._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + ) + + if self.device.states.get(OverkizState.OVP_SWING): + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + if self._attr_device_info: + self._attr_device_info["manufacturer"] = "Hitachi" + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if ( + main_op_state := self.device.states[OverkizState.OVP_MAIN_OPERATION] + ) and main_op_state.value_as_str: + if main_op_state.value_as_str.lower() == OverkizCommandParam.OFF: + return HVACMode.OFF + + if ( + mode_change_state := self.device.states[OverkizState.OVP_MODE_CHANGE] + ) and mode_change_state.value_as_str: + # The OVP protocol has 'auto cooling' and 'auto heating' values + # that are equivalent to the HLRRWIFI protocol without spaces + sanitized_value = mode_change_state.value_as_str.replace(" ", "").lower() + return OVERKIZ_TO_HVAC_MODES[sanitized_value] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.OFF: + await self._global_control(main_operation=OverkizCommandParam.OFF) + else: + await self._global_control( + main_operation=OverkizCommandParam.ON, + hvac_mode=HVAC_MODES_TO_OVERKIZ[hvac_mode], + ) + + @property + def fan_mode(self) -> str | None: + """Return the fan setting.""" + if ( + state := self.device.states[OverkizState.OVP_FAN_SPEED] + ) and state.value_as_str: + return OVERKIZ_TO_FAN_MODES[state.value_as_str] + + return None + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._global_control(fan_mode=FAN_MODES_TO_OVERKIZ[fan_mode]) + + @property + def swing_mode(self) -> str | None: + """Return the swing setting.""" + if (state := self.device.states[OverkizState.OVP_SWING]) and state.value_as_str: + return OVERKIZ_TO_SWING_MODES[state.value_as_str] + + return None + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + await self._global_control(swing_mode=SWING_MODES_TO_OVERKIZ[swing_mode]) + + @property + def target_temperature(self) -> int | None: + """Return the target temperature.""" + if ( + temperature := self.device.states[OverkizState.CORE_TARGET_TEMPERATURE] + ) and temperature.value_as_int: + return temperature.value_as_int + + return None + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if ( + state := self.device.states[OverkizState.OVP_ROOM_TEMPERATURE] + ) and state.value_as_int: + return state.value_as_int + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + await self._global_control(target_temperature=int(kwargs[ATTR_TEMPERATURE])) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + if ( + state := self.device.states[OverkizState.CORE_HOLIDAYS_MODE] + ) and state.value_as_str: + if state.value_as_str == OverkizCommandParam.ON: + return PRESET_HOLIDAY_MODE + + if state.value_as_str == OverkizCommandParam.OFF: + return PRESET_NONE + + return None + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode == PRESET_HOLIDAY_MODE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.ON, + ) + if preset_mode == PRESET_NONE: + await self.executor.async_execute_command( + OverkizCommand.SET_HOLIDAYS, + OverkizCommandParam.OFF, + ) + + # OVP has this property to control the unit's timer mode + @property + def auto_manu_mode(self) -> str | None: + """Return auto/manu mode.""" + if ( + state := self.device.states[OverkizState.CORE_AUTO_MANU_MODE] + ) and state.value_as_str: + return state.value_as_str + return None + + # OVP has this property to control the target temperature delta in auto mode + @property + def temperature_change(self) -> int | None: + """Return temperature change state.""" + if ( + state := self.device.states[OverkizState.OVP_TEMPERATURE_CHANGE] + ) and state.value_as_int: + return state.value_as_int + + return None + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MIN + return TEMP_MIN + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self.hvac_mode == HVACMode.AUTO: + return TEMP_AUTO_MAX + return TEMP_MAX + + def _control_backfill( + self, value: str | None, state_name: str, fallback_value: str + ) -> str: + """Return a parameter value which will be accepted in a command by Overkiz. + + Overkiz doesn't accept commands with undefined parameters. This function + is guaranteed to return a `str` which is the provided `value` if set, or + the current device state if set, or the provided `fallback_value` otherwise. + """ + if value: + return value + if (state := self.device.states[state_name]) is not None and ( + value := state.value_as_str + ) is not None: + return value + return fallback_value + + async def _global_control( + self, + main_operation: str | None = None, + target_temperature: int | None = None, + fan_mode: str | None = None, + hvac_mode: str | None = None, + swing_mode: str | None = None, + leave_home: str | None = None, + ) -> None: + """Execute globalControl command with all parameters. + + There is no option to only set a single parameter, without passing + all other values. + """ + + main_operation = self._control_backfill( + main_operation, OverkizState.OVP_MAIN_OPERATION, OverkizCommandParam.ON + ) + fan_mode = self._control_backfill( + fan_mode, + OverkizState.OVP_FAN_SPEED, + OverkizCommandParam.AUTO, + ) + hvac_mode = self._control_backfill( + hvac_mode, + OverkizState.OVP_MODE_CHANGE, + OverkizCommandParam.AUTO, + ).lower() # Overkiz returns uppercase states that are not acceptable commands + if hvac_mode.replace(" ", "") in [ + # Overkiz returns compound states like 'auto cooling' or 'autoHeating' + # that are not valid commands and need to be mapped to 'auto' + OverkizCommandParam.AUTOCOOLING, + OverkizCommandParam.AUTOHEATING, + ]: + hvac_mode = OverkizCommandParam.AUTO + + swing_mode = self._control_backfill( + swing_mode, + OverkizState.OVP_SWING, + OverkizCommandParam.STOP, + ) + + # AUTO_MANU parameter is not controlled by HA and is turned "off" when the device is on Holiday mode + auto_manu_mode = self._control_backfill( + None, OverkizState.CORE_AUTO_MANU_MODE, OverkizCommandParam.MANU + ) + if self.preset_mode == PRESET_HOLIDAY_MODE: + auto_manu_mode = OverkizCommandParam.OFF + + # In all the hvac modes except AUTO, the temperature command parameter is the target temperature + temperature_command = None + target_temperature = target_temperature or self.target_temperature + if hvac_mode == OverkizCommandParam.AUTO: + # In hvac mode AUTO, the temperature command parameter is a temperature_change + # which is the delta between a pivot temperature (25) and the target temperature + temperature_change = 0 + + if target_temperature: + temperature_change = target_temperature - AUTO_PIVOT_TEMPERATURE + elif self.temperature_change: + temperature_change = self.temperature_change + + # Keep temperature_change in the API accepted range + temperature_change = min( + max(temperature_change, AUTO_TEMPERATURE_CHANGE_MIN), + AUTO_TEMPERATURE_CHANGE_MAX, + ) + + temperature_command = temperature_change + else: + # In other modes, the temperature command is the target temperature + temperature_command = target_temperature + + command_data = [ + main_operation, # Main Operation + temperature_command, # Temperature Command + fan_mode, # Fan Mode + hvac_mode, # Mode + auto_manu_mode, # Auto Manu Mode + ] + + await self.executor.async_execute_command( + OverkizCommand.GLOBAL_CONTROL, command_data + ) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index e5c1665b2e48c4..db24a299f2a95c 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -1,7 +1,13 @@ { "domain": "overkiz", "name": "Overkiz", - "codeowners": ["@imicknl", "@vlebourl", "@tetienne", "@nyroDev"], + "codeowners": [ + "@imicknl", + "@vlebourl", + "@tetienne", + "@nyroDev", + "@tronix117" + ], "config_flow": true, "dhcp": [ { @@ -13,7 +19,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.3"], + "requirements": ["pyoverkiz==1.13.8"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index c15a7bd3accb88..b53dbb5db7596b 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -20,6 +20,7 @@ from . import HomeAssistantOverkizData from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES +from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity BOOST_MODE_DURATION_DELAY = 1 @@ -37,6 +38,8 @@ class OverkizNumberDescriptionMixin: class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescriptionMixin): """Class to describe an Overkiz number.""" + min_value_state_name: str | None = None + max_value_state_name: str | None = None inverted: bool = False set_native_value: Callable[ [float, Callable[..., Awaitable[None]]], Awaitable[None] @@ -94,6 +97,8 @@ async def _async_set_native_value_boost_mode_duration( command=OverkizCommand.SET_EXPECTED_NUMBER_OF_SHOWER, native_min_value=2, native_max_value=4, + min_value_state_name=OverkizState.CORE_MINIMAL_SHOWER_MANUAL_MODE, + max_value_state_name=OverkizState.CORE_MAXIMAL_SHOWER_MANUAL_MODE, entity_category=EntityCategory.CONFIG, ), # SomfyHeatingTemperatureInterface @@ -200,6 +205,29 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): entity_description: OverkizNumberDescription + def __init__( + self, + device_url: str, + coordinator: OverkizDataUpdateCoordinator, + description: OverkizNumberDescription, + ) -> None: + """Initialize a device.""" + super().__init__(device_url, coordinator, description) + + if self.entity_description.min_value_state_name and ( + state := self.device.states.get( + self.entity_description.min_value_state_name + ) + ): + self._attr_native_min_value = cast(float, state.value) + + if self.entity_description.max_value_state_name and ( + state := self.device.states.get( + self.entity_description.max_value_state_name + ) + ): + self._attr_native_max_value = cast(float, state.value) + @property def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index 38b6a351f2945c..80ab929231e1f5 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -159,12 +159,8 @@ async def async_load_data(self, config): """Load the data.""" self._data = config - self._data[CONF_PORT] = ( - self._data[CONF_PORT] if CONF_PORT in self._data else DEFAULT_PORT - ) - self._data[CONF_ON_ACTION] = ( - self._data[CONF_ON_ACTION] if CONF_ON_ACTION in self._data else None - ) + self._data[CONF_PORT] = self._data.get(CONF_PORT, DEFAULT_PORT) + self._data[CONF_ON_ACTION] = self._data.get(CONF_ON_ACTION) await self.async_set_unique_id(self._data[CONF_HOST]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index bcdc4195100b7b..52e988f0f608ba 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -29,7 +29,7 @@ SMART_METER_SCAN_INTERVAL, ) -PLATFORMS: Final = [Platform.SENSOR, Platform.BINARY_SENSOR] +PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] @dataclass diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index f38d320b454294..d193fd7487a684 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], - "requirements": ["aiopegelonline==0.0.8"] + "requirements": ["aiopegelonline==0.0.9"] } diff --git a/homeassistant/components/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 2f3c4c04c50254..0213fb6a4b692a 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -16,6 +16,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import APPLICATION, DOMAIN from .coordinator import MyPermobilCoordinator @@ -29,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up MyPermobil from a config entry.""" # create the API object from the config and save it in hass - session = hass.helpers.aiohttp_client.async_get_clientsession() + session = async_get_clientsession(hass) p_api = MyPermobil( application=APPLICATION, session=session, diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 8a504248f5ac80..8af6fcf5ab18e9 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -177,6 +177,7 @@ class PermobilSensorEntityDescription( key="record_distance", translation_key="record_distance", icon="mdi:map-marker-distance", + device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.TOTAL_INCREASING, ), ) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 3a7db24886275e..2075c3fc7139dd 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -482,18 +482,24 @@ async def async_added_to_hass(self) -> None: if self.hass.is_running: # Update person now if hass is already running. - await self.async_update_config(self._config) + self._async_update_config(self._config) else: # Wait for hass start to not have race between person # and device trackers finishing setup. - async def person_start_hass(_: Event) -> None: - await self.async_update_config(self._config) + @callback + def _async_person_start_hass(_: Event) -> None: + self._async_update_config(self._config) self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, person_start_hass + EVENT_HOMEASSISTANT_START, _async_person_start_hass ) async def async_update_config(self, config: ConfigType) -> None: + """Handle when the config is updated.""" + self._async_update_config(config) + + @callback + def _async_update_config(self, config: ConfigType) -> None: """Handle when the config is updated.""" self._config = config diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py index 00a9f534852156..61af7e5cc91d4a 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -42,7 +42,7 @@ def __init__( async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # Note: TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 60568e722efccb..f4d2e539b6a40f 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -5,7 +5,6 @@ from datetime import timedelta import functools import logging -import socket import threading from typing import Any, ParamSpec @@ -75,7 +74,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: pilight_client = pilight.Client(host=host, port=port) - except (OSError, socket.timeout) as err: + except (OSError, TimeoutError) as err: _LOGGER.error("Unable to connect to %s on port %s: %s", host, port, err) return False @@ -117,11 +116,8 @@ def handle_received_code(data): {"protocol": data["protocol"], "uuid": data["uuid"]}, **data["message"] ) - # No whitelist defined, put data on event bus - if not whitelist: - hass.bus.fire(EVENT, data) - # Check if data matches the defined whitelist - elif all(str(data[key]) in whitelist[key] for key in whitelist): + # No whitelist defined or data matches whitelist, put data on event bus + if not whitelist or all(str(data[key]) in whitelist[key] for key in whitelist): hass.bus.fire(EVENT, data) pilight_client.set_callback(handle_received_code) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index ce3d5c3b461d04..e3ebaffec1208d 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -141,7 +141,7 @@ async def async_ping(self) -> dict[str, Any] | None: assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.exception( "Timed out running command: `%s`, after: %ss", self._ping_cmd, diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 4bbf1225a92dd1..1a7ff877bb8ff1 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -1,8 +1,6 @@ """Support for controlling projector via the PJLink protocol.""" from __future__ import annotations -import socket - from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -116,7 +114,7 @@ def projector(self): try: projector = Projector.from_address(self._host, self._port) projector.authenticate(self._password) - except (socket.timeout, OSError) as err: + except (TimeoutError, OSError) as err: self._attr_available = False raise ProjectorError(ERR_PROJECTOR_UNAVAILABLE) from err diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 8fc01140787d6f..99dd44c1ed8f86 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -5,10 +5,11 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/plex", + "import_executor": true, "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.7", + "PlexAPI==4.15.10", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plugwise/icons.json b/homeassistant/components/plugwise/icons.json index 4af2c0b4c75121..2a57dd4350fdc9 100644 --- a/homeassistant/components/plugwise/icons.json +++ b/homeassistant/components/plugwise/icons.json @@ -64,16 +64,38 @@ }, "select": { "dhw_mode": { - "default": "mdi:shower" + "default": "mdi:shower", + "state": { + "comfort": "mdi:sofa", + "eco": "mdi:leaf", + "off": "mdi:circle-off-outline", + "boost": "mdi:rocket-launch", + "auto": "mdi:auto-mode" + } }, "gateway_mode": { - "default": "mdi:cog-outline" + "default": "mdi:cog-outline", + "state": { + "away": "mdi:pause", + "full": "mdi:home", + "vacation": "mdi:beach" + } }, "regulation_mode": { - "default": "mdi:hvac" + "default": "mdi:hvac", + "state": { + "bleeding_hot": "mdi:fire-circle", + "bleeding_cold": "mdi:water-circle", + "off": "mdi:circle-off-outline", + "heating": "mdi:radiator", + "cooling": "mdi:snowflake" + } }, "select_schedule": { - "default": "mdi:calendar-clock" + "default": "mdi:calendar-clock", + "state": { + "off": "mdi:circle-off-outline" + } } }, "sensor": { diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 201e397ba7d97e..718e4a831c99d8 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -95,7 +95,7 @@ async def async_step_auth(self, user_input=None): try: async with asyncio.timeout(10): url = await self._get_authorization_url() - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="authorize_url_timeout") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error generating auth url") diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index d975537ca61633..086e49eef94eba 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -1,7 +1,6 @@ """The Tesla Powerwall integration.""" from __future__ import annotations -import asyncio from contextlib import AsyncExitStack from datetime import timedelta import logging @@ -89,7 +88,7 @@ async def _update_data(self) -> PowerwallData: if attempt == 1: await self._recreate_powerwall_login() data = await _fetch_powerwall_data(self.power_wall) - except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + except (TimeoutError, PowerwallUnreachableError) as err: raise UpdateFailed("Unable to fetch data from powerwall") from err except MissingAttributeError as err: _LOGGER.error("The powerwall api has changed: %s", str(err)) @@ -136,7 +135,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cancel closing power_wall on success stack.pop_all() - except (asyncio.TimeoutError, PowerwallUnreachableError) as err: + except (TimeoutError, PowerwallUnreachableError) as err: raise ConfigEntryNotReady from err except MissingAttributeError as err: # The error might include some important information about what exactly changed. @@ -221,35 +220,26 @@ async def _login_and_fetch_base_info( async def _call_base_info(power_wall: Powerwall, host: str) -> PowerwallBaseInfo: """Return PowerwallBaseInfo for the device.""" - - try: - async with asyncio.TaskGroup() as tg: - gateway_din = tg.create_task(power_wall.get_gateway_din()) - site_info = tg.create_task(power_wall.get_site_info()) - status = tg.create_task(power_wall.get_status()) - device_type = tg.create_task(power_wall.get_device_type()) - serial_numbers = tg.create_task(power_wall.get_serial_numbers()) - batteries = tg.create_task(power_wall.get_batteries()) - - # Mimic the behavior of asyncio.gather by reraising the first caught exception since - # this is what is expected by the caller of this method - # - # While it would have been cleaner to use asyncio.gather in the first place instead of - # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to - # missing typing information. - except BaseExceptionGroup as e: - raise e.exceptions[0] from None - + # We await each call individually since the powerwall + # supports http keep-alive and we want to reuse the connection + # as its faster than establishing a new connection when + # run concurrently. + gateway_din = await power_wall.get_gateway_din() + site_info = await power_wall.get_site_info() + status = await power_wall.get_status() + device_type = await power_wall.get_device_type() + serial_numbers = await power_wall.get_serial_numbers() + batteries = await power_wall.get_batteries() # Serial numbers MUST be sorted to ensure the unique_id is always the same # for backwards compatibility. return PowerwallBaseInfo( - gateway_din=gateway_din.result().upper(), - site_info=site_info.result(), - status=status.result(), - device_type=device_type.result(), - serial_numbers=sorted(serial_numbers.result()), + gateway_din=gateway_din, + site_info=site_info, + status=status, + device_type=device_type, + serial_numbers=sorted(serial_numbers), url=f"https://{host}", - batteries={battery.serial_number: battery for battery in batteries.result()}, + batteries={battery.serial_number: battery for battery in batteries}, ) @@ -263,34 +253,25 @@ async def get_backup_reserve_percentage(power_wall: Powerwall) -> Optional[float async def _fetch_powerwall_data(power_wall: Powerwall) -> PowerwallData: """Process and update powerwall data.""" - - try: - async with asyncio.TaskGroup() as tg: - backup_reserve = tg.create_task(get_backup_reserve_percentage(power_wall)) - charge = tg.create_task(power_wall.get_charge()) - site_master = tg.create_task(power_wall.get_sitemaster()) - meters = tg.create_task(power_wall.get_meters()) - grid_services_active = tg.create_task(power_wall.is_grid_services_active()) - grid_status = tg.create_task(power_wall.get_grid_status()) - batteries = tg.create_task(power_wall.get_batteries()) - - # Mimic the behavior of asyncio.gather by reraising the first caught exception since - # this is what is expected by the caller of this method - # - # While it would have been cleaner to use asyncio.gather in the first place instead of - # TaskGroup but in cases where you have more than 6 tasks, the linter fails due to - # missing typing information. - except BaseExceptionGroup as e: - raise e.exceptions[0] from None - + # We await each call individually since the powerwall + # supports http keep-alive and we want to reuse the connection + # as its faster than establishing a new connection when + # run concurrently. + backup_reserve = await get_backup_reserve_percentage(power_wall) + charge = await power_wall.get_charge() + site_master = await power_wall.get_sitemaster() + meters = await power_wall.get_meters() + grid_services_active = await power_wall.is_grid_services_active() + grid_status = await power_wall.get_grid_status() + batteries = await power_wall.get_batteries() return PowerwallData( - charge=charge.result(), - site_master=site_master.result(), - meters=meters.result(), - grid_services_active=grid_services_active.result(), - grid_status=grid_status.result(), - backup_reserve=backup_reserve.result(), - batteries={battery.serial_number: battery for battery in batteries.result()}, + charge=charge, + site_master=site_master, + meters=meters, + grid_services_active=grid_services_active, + grid_status=grid_status, + backup_reserve=backup_reserve, + batteries={battery.serial_number: battery for battery in batteries}, ) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index e86949e222709c..8b347ef49c1dd3 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -125,18 +125,14 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes if self.hass.config_entries.async_update_entry( entry, unique_id=gateway_din ): - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) return self.async_abort(reason="already_configured") if entry.unique_id == gateway_din: if await self._async_powerwall_is_offline(entry): if self.hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_IP_ADDRESS: self.ip_address} ): - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) return self.async_abort(reason="already_configured") # Still need to abort for ignored entries self._abort_if_unique_id_configured() @@ -166,7 +162,7 @@ async def _async_try_connect( description_placeholders: dict[str, str] = {} try: info = await validate_input(self.hass, user_input) - except (PowerwallUnreachableError, asyncio.TimeoutError) as ex: + except (PowerwallUnreachableError, TimeoutError) as ex: errors[CONF_IP_ADDRESS] = "cannot_connect" description_placeholders = {"error": str(ex)} except WrongVersion as ex: diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index 4185e90ab7b55e..df57396c7bf6b6 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -12,6 +12,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/powerwall", + "import_executor": true, "iot_class": "local_polling", "loggers": ["tesla_powerwall"], "requirements": ["tesla-powerwall==0.5.1"] diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 5e4408bba20be6..89240820057730 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -45,7 +45,6 @@ "StatesMetaManager", "StateAttributesManager", "StatisticsMetaManager", - "IntegrationMatcher", ) SERVICES = ( diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index d0b35aaf4b97ca..c365ce151ec158 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -73,5 +73,5 @@ async def async_send_message(self, message, **kwargs): response.status, result, ) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout accessing Prowl at %s", url) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index f3306bebf3975d..1a549d22f819d5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.person import DOMAIN as PERSON_DOMAIN from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_ZONE +from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( @@ -50,7 +50,9 @@ def _base_schema(user_input: dict[str, Any]) -> vol.Schema: CONF_TOLERANCE, default=user_input.get(CONF_TOLERANCE, DEFAULT_TOLERANCE), ): NumberSelector( - NumberSelectorConfig(min=1, max=100, step=1), + NumberSelectorConfig( + min=1, max=100, step=1, unit_of_measurement=UnitOfLength.METERS + ), ), } diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 94cf21e13dfb7c..08670ef543344f 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -113,9 +113,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_data[CONF_PASSWORD] = password ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") - config_entry.minor_version = 2 - - hass.config_entries.async_update_entry(config_entry, data=new_data) + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2 + ) return True diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 378c5e7395a873..e4e9b9d719c595 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -50,7 +50,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, async with asyncio.timeout(5): version = await api.get_version() - except (asyncio.TimeoutError, ClientError) as err: + except (TimeoutError, ClientError) as err: _LOGGER.error("Could not connect to PrusaLink: %s", err) raise CannotConnect from err diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 1c87a275126594..f68ad6ce896456 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -108,8 +108,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if country in COUNTRIES: for device in data["devices"]: device[CONF_REGION] = country - version = entry.version = 2 - config_entries.async_update_entry(entry, data=data) + version = 2 + config_entries.async_update_entry(entry, data=data, version=2) _LOGGER.info( "PlayStation 4 Config Updated: Region changed to: %s", country, @@ -120,33 +120,34 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Prevent changing entity_id. Updates entity registry. registry = er.async_get(hass) - for entity_id, e_entry in registry.entities.items(): - if e_entry.config_entry_id == entry.entry_id: - unique_id = e_entry.unique_id - - # Remove old entity entry. - registry.async_remove(entity_id) - - # Format old unique_id. - unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) - - # Create new entry with old entity_id. - new_id = split_entity_id(entity_id)[1] - registry.async_get_or_create( - "media_player", - DOMAIN, - unique_id, - suggested_object_id=new_id, - config_entry=entry, - device_id=e_entry.device_id, - ) - entry.version = 3 - _LOGGER.info( - "PlayStation 4 identifier for entity: %s has changed", - entity_id, - ) - config_entries.async_update_entry(entry) - return True + for e_entry in registry.entities.get_entries_for_config_entry_id( + entry.entry_id + ): + unique_id = e_entry.unique_id + entity_id = e_entry.entity_id + + # Remove old entity entry. + registry.async_remove(entity_id) + + # Format old unique_id. + unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id) + + # Create new entry with old entity_id. + new_id = split_entity_id(entity_id)[1] + registry.async_get_or_create( + "media_player", + DOMAIN, + unique_id, + suggested_object_id=new_id, + config_entry=entry, + device_id=e_entry.device_id, + ) + _LOGGER.info( + "PlayStation 4 identifier for entity: %s has changed", + entity_id, + ) + config_entries.async_update_entry(entry, version=3) + return True msg = f"""{reason[version]} for the PlayStation 4 Integration. Please remove the PS4 Integration and re-configure diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index f14ef6ce2aa8c9..42a1021afe4dd2 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -1,5 +1,4 @@ """Support for PlayStation 4 consoles.""" -import asyncio from contextlib import suppress import logging from typing import Any, cast @@ -257,7 +256,7 @@ async def async_get_title_data(self, title_id: str, name: str) -> None: except PSDataIncomplete: title = None - except asyncio.TimeoutError: + except TimeoutError: title = None _LOGGER.error("PS Store Search Timed out") @@ -345,11 +344,13 @@ async def async_get_device_info(self, status: dict[str, Any] | None) -> None: _LOGGER.info("Assuming status from registry") 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 - self.entity_id = entity_id - break + + for entry in e_registry.entities.get_entries_for_config_entry_id( + self._entry_id + ): + self._attr_unique_id = entry.unique_id + self.entity_id = entry.entity_id + break for device in d_registry.devices.values(): if self._entry_id in device.config_entries: self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index a4fec1c3d4dfd5..2cedcb8598aae4 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -75,7 +75,7 @@ async def handle_webhook(hass, webhook_id, request): try: async with asyncio.timeout(5): data = dict(await request.post()) - except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: + except (TimeoutError, aiohttp.web.HTTPException) as error: _LOGGER.error("Could not get information from POST <%s>", error) return diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 7b49a6b1b0d173..c10c6de5b3f09a 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -2,8 +2,11 @@ import datetime import glob import logging +from numbers import Number +import operator import os import time +from typing import Any from RestrictedPython import ( compile_restricted_exec, @@ -146,6 +149,36 @@ def python_script_service_handler(call: ServiceCall) -> ServiceResponse: async_set_service_schema(hass, DOMAIN, name, service_desc) +IOPERATOR_TO_OPERATOR = { + "%=": operator.mod, + "&=": operator.and_, + "**=": operator.pow, + "*=": operator.mul, + "+=": operator.add, + "-=": operator.sub, + "//=": operator.floordiv, + "/=": operator.truediv, + "<<=": operator.lshift, + ">>=": operator.rshift, + "@=": operator.matmul, + "^=": operator.xor, + "|=": operator.or_, +} + + +def guarded_inplacevar(op: str, target: Any, operand: Any) -> Any: + """Implement augmented-assign (+=, -=, etc.) operators for restricted code. + + See RestrictedPython's `visit_AugAssign` for details. + """ + if not isinstance(target, (list, Number, str)): + raise ScriptError(f"The {op!r} operation is not allowed on a {type(target)}") + op_fun = IOPERATOR_TO_OPERATOR.get(op) + if not op_fun: + raise ScriptError(f"The {op!r} operation is not allowed") + return op_fun(target, operand) + + @bind_hass def execute_script(hass, name, data=None, return_response=False): """Execute a script.""" @@ -223,6 +256,7 @@ def protected_getattr(obj, name, default=None): "_getitem_": default_guarded_getitem, "_iter_unpack_sequence_": guarded_iter_unpack_sequence, "_unpack_sequence_": guarded_unpack_sequence, + "_inplacevar_": guarded_inplacevar, "hass": hass, "data": data or {}, "logger": logger, diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index 5b7837a96947c9..a90085afb4f79f 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Qingping integration.""" from __future__ import annotations -import asyncio from typing import Any from qingping_ble import QingpingBluetoothDeviceData as DeviceData @@ -62,7 +61,7 @@ async def async_step_bluetooth( self._discovery_info = await self._async_wait_for_full_advertisement( discovery_info, device ) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="not_supported") self._discovery_info = discovery_info self._discovered_device = device diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 5cde039c5cee8d..c25652ca91e212 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/qingping", "iot_class": "local_push", - "requirements": ["qingping-ble==0.9.0"] + "requirements": ["qingping-ble==0.10.0"] } diff --git a/homeassistant/components/qld_bushfire/manifest.json b/homeassistant/components/qld_bushfire/manifest.json index 5e7d9948309253..94ccbbd4c18906 100644 --- a/homeassistant/components/qld_bushfire/manifest.json +++ b/homeassistant/components/qld_bushfire/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["georss_qld_bushfire_alert_client"], - "requirements": ["georss-qld-bushfire-alert-client==0.5"] + "requirements": ["georss-qld-bushfire-alert-client==0.7"] } diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py index 70cd07f4d916a7..e265740179d715 100644 --- a/homeassistant/components/rabbitair/config_flow.py +++ b/homeassistant/components/rabbitair/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Rabbit Air integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -36,7 +35,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, except ValueError as err: # Most likely caused by the invalid access token. raise InvalidAccessToken from err - except asyncio.TimeoutError as err: + except TimeoutError as err: # Either the host doesn't respond or the auth failed. raise TimeoutConnect from err except OSError as err: diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json index 3aa94e0d402678..e8e03fd18282df 100644 --- a/homeassistant/components/radio_browser/manifest.json +++ b/homeassistant/components/radio_browser/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/radio_browser", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["radios==0.2.0"] diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 808ee56b092b28..86a9fe58013ca5 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Coroutine -from socket import timeout from typing import Any, TypeVar from urllib.error import URLError @@ -32,7 +31,7 @@ async def _async_call_or_raise_not_ready( except RadiothermTstatError as ex: msg = f"{host} was busy (invalid value returned): {ex}" raise ConfigEntryNotReady(msg) from ex - except timeout as ex: + except TimeoutError as ex: msg = f"{host} timed out waiting for a response: {ex}" raise ConfigEntryNotReady(msg) from ex except (OSError, URLError) as ex: diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index ca488ade461eed..c370cc86484724 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -from socket import timeout from typing import Any from urllib.error import URLError @@ -30,7 +29,7 @@ async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitD """Validate the connection.""" try: return await async_get_init_data(hass, host) - except (timeout, RadiothermTstatError, URLError, OSError) as ex: + except (TimeoutError, RadiothermTstatError, URLError, OSError) as ex: raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index ffc6bfcc8bab51..5b0161d9f22f37 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -3,7 +3,6 @@ from datetime import timedelta import logging -from socket import timeout from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -39,7 +38,7 @@ async def _async_update_data(self) -> RadioThermUpdate: except RadiothermTstatError as ex: msg = f"{self._description} was busy (invalid value returned): {ex}" raise UpdateFailed(msg) from ex - except timeout as ex: + except TimeoutError as ex: msg = f"{self._description}) timed out waiting for a response: {ex}" raise UpdateFailed(msg) from ex except (OSError, URLError) as ex: diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f7eab3bc2f2c3b..2a660435e17a3d 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -11,11 +11,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from .const import CONF_SERIAL_NUMBER -from .coordinator import RainbirdData +from .coordinator import RainbirdData, async_create_clientsession _LOGGER = logging.getLogger(__name__) @@ -36,9 +35,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) + clientsession = async_create_clientsession() + entry.async_on_unload(clientsession.close) controller = AsyncRainbirdController( AsyncRainbirdClient( - async_get_clientsession(hass), + clientsession, entry.data[CONF_HOST], entry.data[CONF_PASSWORD], ) diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index f90e13d37f3e45..a4fceceede99ac 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -20,7 +20,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from .const import ( @@ -30,6 +29,7 @@ DOMAIN, TIMEOUT_SECONDS, ) +from .coordinator import async_create_clientsession _LOGGER = logging.getLogger(__name__) @@ -101,9 +101,10 @@ async def _test_connection( Raises a ConfigFlowError on failure. """ + clientsession = async_create_clientsession() controller = AsyncRainbirdController( AsyncRainbirdClient( - async_get_clientsession(self.hass), + clientsession, host, password, ) @@ -114,7 +115,7 @@ async def _test_connection( controller.get_serial_number(), controller.get_wifi_params(), ) - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigFlowError( f"Timeout connecting to Rain Bird controller: {str(err)}", "timeout_connect", @@ -124,6 +125,8 @@ async def _test_connection( f"Error connecting to Rain Bird controller: {str(err)}", "cannot_connect", ) from err + finally: + await clientsession.close() async def async_finish( self, diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 9f1ea95b333767..70365c2f095967 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -9,6 +9,7 @@ import logging from typing import TypeVar +import aiohttp from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -18,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -28,6 +30,13 @@ # changes, so we refresh it less often. CALENDAR_UPDATE_INTERVAL = datetime.timedelta(minutes=15) +# The valves state are not immediately reflected after issuing a command. We add +# small delay to give additional time to reflect the new state. +DEBOUNCER_COOLDOWN = 5 + +# Rainbird devices can only accept a single request at a time +CONECTION_LIMIT = 1 + _LOGGER = logging.getLogger(__name__) _T = TypeVar("_T") @@ -43,6 +52,13 @@ class RainbirdDeviceState: rain_delay: int +def async_create_clientsession() -> aiohttp.ClientSession: + """Create a rainbird async_create_clientsession with a connection limit.""" + return aiohttp.ClientSession( + connector=aiohttp.TCPConnector(limit=CONECTION_LIMIT), + ) + + class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): """Coordinator for rainbird API calls.""" @@ -60,6 +76,9 @@ def __init__( _LOGGER, name=name, update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=DEBOUNCER_COOLDOWN, immediate=False + ), ) self._controller = controller self._unique_id = unique_id diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index b8cb86264f236f..7823626f54c883 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rainbird", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==4.0.1"] + "requirements": ["pyrainbird==4.0.2"] } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index da3979a27fd652..810a6fbb721b06 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -103,6 +103,10 @@ async def async_turn_on(self, **kwargs): except RainbirdApiException as err: raise HomeAssistantError("Rain Bird device failure") from err + # The device reflects the old state for a few moments. Update the + # state manually and trigger a refresh after a short debounced delay. + self.coordinator.data.active_zones.add(self._zone) + self.async_write_ha_state() await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs): @@ -115,6 +119,11 @@ async def async_turn_off(self, **kwargs): ) from err except RainbirdApiException as err: raise HomeAssistantError("Rain Bird device failure") from err + + # The device reflects the old state for a few moments. Update the + # state manually and trigger a refresh after a short debounced delay. + self.coordinator.data.active_zones.remove(self._zone) + self.async_write_ha_state() await self.coordinator.async_request_refresh() @property diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index cd8ce68c7e7c5c..2f0234efb7a26a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -106,7 +106,7 @@ async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult await self.async_set_unique_id(unique_id) try: await self._validate_device(dev_path) - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="timeout_connect") except RAVEnConnectionError: return self.async_abort(reason="cannot_connect") @@ -147,7 +147,7 @@ async def async_step_user( await self.async_set_unique_id(unique_id) try: await self._validate_device(dev_path) - except asyncio.TimeoutError: + except TimeoutError: errors[CONF_DEVICE] = "timeout_connect" except RAVEnConnectionError: errors[CONF_DEVICE] = "cannot_connect" diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json index 900c947821dd0e..3e463af9ba4c36 100644 --- a/homeassistant/components/rainforest_raven/manifest.json +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "iot_class": "local_polling", - "requirements": ["aioraven==0.5.0"], + "requirements": ["aioraven==0.5.1"], "usb": [ { "vid": "0403", diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 5c3ff18f71c549..2e821fc7a7ab72 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -1,7 +1,6 @@ """Support for RainMachine devices.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta @@ -206,13 +205,10 @@ async def async_update_programs_and_zones( programs affect zones and certain combinations of zones affect programs. """ data: RainMachineData = hass.data[DOMAIN][entry.entry_id] - - await asyncio.gather( - *[ - data.coordinators[DATA_PROGRAMS].async_refresh(), - data.coordinators[DATA_ZONES].async_refresh(), - ] - ) + # No gather here to allow http keep-alive to reuse + # the connection for each coordinator. + await data.coordinators[DATA_PROGRAMS].async_refresh() + await data.coordinators[DATA_ZONES].async_refresh() async def async_setup_entry( # noqa: C901 @@ -302,14 +298,6 @@ async def async_update(api_category: str) -> dict: return data - async def async_init_coordinator( - coordinator: RainMachineDataUpdateCoordinator, - ) -> None: - """Initialize a RainMachineDataUpdateCoordinator.""" - await coordinator.async_initialize() - await coordinator.async_config_entry_first_refresh() - - controller_init_tasks = [] coordinators = {} for api_category, update_interval in COORDINATOR_UPDATE_INTERVAL_MAP.items(): coordinator = coordinators[api_category] = RainMachineDataUpdateCoordinator( @@ -320,9 +308,11 @@ async def async_init_coordinator( update_interval=update_interval, update_method=partial(async_update, api_category), ) - controller_init_tasks.append(async_init_coordinator(coordinator)) - - await asyncio.gather(*controller_init_tasks) + coordinator.async_initialize() + # Its generally faster not to gather here so we can + # reuse the connection instead of creating a new + # connection for each coordinator. + await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = RainMachineData( @@ -507,7 +497,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique IDs to be consistent across platform (including removing # the silly removal of colons in the MAC address that was added originally): if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index dfb03b11b5d3f1..a557b701824dbe 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -60,9 +60,10 @@ def async_finish_entity_domain_replacements( try: [registry_entry] = [ registry_entry - for registry_entry in ent_reg.entities.values() - if registry_entry.config_entry_id == entry.entry_id - and registry_entry.domain == strategy.old_domain + for registry_entry in ent_reg.entities.get_entries_for_config_entry_id( + entry.entry_id + ) + if registry_entry.domain == strategy.old_domain and registry_entry.unique_id == strategy.old_unique_id ] except ValueError: @@ -119,7 +120,8 @@ def __init__( self.config_entry.entry_id ) - async def async_initialize(self) -> None: + @callback + def async_initialize(self) -> None: """Initialize the coordinator.""" @callback diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json index 164f184ae884f8..0faad1d80935c1 100644 --- a/homeassistant/components/random/strings.json +++ b/homeassistant/components/random/strings.json @@ -19,7 +19,7 @@ "title": "Random sensor" }, "user": { - "description": "This helper allow you to create a helper that emits a random value.", + "description": "This helper allows you to create a helper that emits a random value.", "menu_options": { "binary_sensor": "Random binary sensor", "sensor": "Random sensor" diff --git a/homeassistant/components/raspberry_pi/__init__.py b/homeassistant/components/raspberry_pi/__init__.py index 3750a1c70688eb..19697d9b69d043 100644 --- a/homeassistant/components/raspberry_pi/__init__.py +++ b/homeassistant/components/raspberry_pi/__init__.py @@ -1,7 +1,7 @@ """The Raspberry Pi integration.""" from __future__ import annotations -from homeassistant.components.hassio import get_os_info +from homeassistant.components.hassio import get_os_info, is_hassio from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -9,6 +9,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Raspberry Pi config entry.""" + if not is_hassio(hass): + # Not running under supervisor, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + if (os_info := get_os_info(hass)) is None: # The hassio integration has not yet fetched data from the supervisor raise ConfigEntryNotReady diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json index d30c637d2c3afd..5ed68154ce16b9 100644 --- a/homeassistant/components/raspberry_pi/manifest.json +++ b/homeassistant/components/raspberry_pi/manifest.json @@ -1,9 +1,10 @@ { "domain": "raspberry_pi", "name": "Raspberry Pi", + "after_dependencies": ["hassio"], "codeowners": ["@home-assistant/core"], "config_flow": false, - "dependencies": ["hardware", "hassio"], + "dependencies": ["hardware"], "documentation": "https://www.home-assistant.io/integrations/raspberry_pi", "integration_type": "hardware" } diff --git a/homeassistant/components/raven_rock_mfg/__init__.py b/homeassistant/components/raven_rock_mfg/__init__.py new file mode 100644 index 00000000000000..b8bd4e9f0a2bce --- /dev/null +++ b/homeassistant/components/raven_rock_mfg/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Raven Rock MFG.""" diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 076067312eb63b..5f3a50e93ed6e8 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -92,7 +92,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique ID of existing, single sensor entity to be consistent with # common format for platforms going forward: if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 5989fb1cfe347a..da5394a934176d 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -92,3 +92,5 @@ def _handle_coordinator_update(self) -> None: ATTR_PICKUP_TYPES ] = async_get_pickup_type_names(self._entry, event.pickup_types) self._attr_native_value = event.date + + super()._handle_coordinator_update() diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c82d431a8fae21..2217d6c7d4e25d 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -12,7 +12,7 @@ EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 EVENT_STATE_CHANGED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -178,7 +178,8 @@ async def _async_setup_integration_platform( ) -> None: """Set up a recorder integration platform.""" - async def _process_recorder_platform( + @callback + def _process_recorder_platform( hass: HomeAssistant, domain: str, platform: Any ) -> None: """Process a recorder platform.""" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 07591c468b8b2a..8885116dbfd8de 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -187,6 +187,7 @@ def __init__( self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days + self.is_running: bool = False self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval self._queue: queue.SimpleQueue[RecorderTask | Event] = queue.SimpleQueue() @@ -694,6 +695,7 @@ def _wait_startup_or_shutdown(self) -> object | None: def run(self) -> None: """Run the recorder thread.""" + self.is_running = True try: self._run() except Exception: # pylint: disable=broad-exception-caught @@ -703,6 +705,7 @@ def run(self) -> None: finally: # Ensure shutdown happens cleanly if # anything goes wrong in the run loop + self.is_running = False self._shutdown() def _add_to_session(self, session: Session, obj: object) -> None: @@ -1335,7 +1338,7 @@ async def lock_database(self) -> bool: try: async with asyncio.timeout(DB_LOCK_TIMEOUT): await database_locked.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: task.database_unlock.set() raise TimeoutError( f"Could not lock database within {DB_LOCK_TIMEOUT} seconds." diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 13ba7400952a98..98b6d15facb26e 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.25", + "SQLAlchemy==2.0.27", "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 0b63bb8daa2bd7..a9d8c0b248256a 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -10,8 +10,6 @@ from sqlalchemy.orm.session import Session -import homeassistant.util.dt as dt_util - from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( @@ -251,7 +249,7 @@ def _select_state_attributes_ids_to_purge( state_ids = set() attributes_ids = set() for state_id, attributes_id in session.execute( - find_states_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) + find_states_to_purge(purge_before.timestamp(), max_bind_vars) ).all(): state_ids.add(state_id) if attributes_id: @@ -271,7 +269,7 @@ def _select_event_data_ids_to_purge( event_ids = set() data_ids = set() for event_id, data_id in session.execute( - find_events_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) + find_events_to_purge(purge_before.timestamp(), max_bind_vars) ).all(): event_ids.add(event_id) if data_id: @@ -464,7 +462,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( """ states = session.execute( find_legacy_detached_states_and_attributes_to_purge( - dt_util.utc_to_timestamp(purge_before), max_bind_vars + purge_before.timestamp(), max_bind_vars ) ).all() _LOGGER.debug("Selected %s state ids to remove", len(states)) @@ -489,7 +487,7 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( """ events = session.execute( find_legacy_event_state_and_attributes_and_data_ids_to_purge( - dt_util.utc_to_timestamp(purge_before), max_bind_vars + purge_before.timestamp(), max_bind_vars ) ).all() _LOGGER.debug("Selected %s event ids to remove", len(events)) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 39821cb96991b0..f2b4df1d0cca83 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime as dt -import logging from typing import Any, Literal, cast import voluptuous as vol @@ -44,15 +43,7 @@ statistics_during_period, validate_statistics, ) -from .util import ( - PERIOD_SCHEMA, - async_migration_in_progress, - async_migration_is_live, - get_instance, - resolve_period, -) - -_LOGGER: logging.Logger = logging.getLogger(__package__) +from .util import PERIOD_SCHEMA, get_instance, resolve_period UNIT_SCHEMA = vol.Schema( { @@ -79,8 +70,6 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the recorder websocket API.""" websocket_api.async_register_command(hass, ws_adjust_sum_statistics) - websocket_api.async_register_command(hass, ws_backup_end) - websocket_api.async_register_command(hass, ws_backup_start) websocket_api.async_register_command(hass, ws_change_statistics_unit) websocket_api.async_register_command(hass, ws_clear_statistics) websocket_api.async_register_command(hass, ws_get_statistic_during_period) @@ -497,55 +486,29 @@ def ws_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Return status of the recorder.""" - instance = get_instance(hass) - - backlog = instance.backlog if instance else None - migration_in_progress = async_migration_in_progress(hass) - migration_is_live = async_migration_is_live(hass) - recording = instance.recording if instance else False - thread_alive = instance.is_alive() if instance else False + if instance := get_instance(hass): + backlog = instance.backlog + migration_in_progress = instance.migration_in_progress + migration_is_live = instance.migration_is_live + recording = instance.recording + # We avoid calling is_alive() as it can block waiting + # for the thread state lock which will block the event loop. + is_running = instance.is_running + max_backlog = instance.max_backlog + else: + backlog = None + migration_in_progress = False + migration_is_live = False + recording = False + is_running = False + max_backlog = None recorder_info = { "backlog": backlog, - "max_backlog": instance.max_backlog, + "max_backlog": max_backlog, "migration_in_progress": migration_in_progress, "migration_is_live": migration_is_live, "recording": recording, - "thread_running": thread_alive, + "thread_running": is_running, } connection.send_result(msg["id"], recorder_info) - - -@websocket_api.ws_require_user(only_supervisor=True) -@websocket_api.websocket_command({vol.Required("type"): "backup/start"}) -@websocket_api.async_response -async def ws_backup_start( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Backup start notification.""" - - _LOGGER.info("Backup start notification, locking database for writes") - instance = get_instance(hass) - try: - await instance.lock_database() - except TimeoutError as err: - connection.send_error(msg["id"], "timeout_error", str(err)) - return - connection.send_result(msg["id"]) - - -@websocket_api.ws_require_user(only_supervisor=True) -@websocket_api.websocket_command({vol.Required("type"): "backup/end"}) -@websocket_api.async_response -async def ws_backup_end( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Backup end notification.""" - - instance = get_instance(hass) - _LOGGER.info("Backup end notification, releasing write lock") - if not instance.unlock_database(): - connection.send_error( - msg["id"], "database_unlock_failed", "Failed to unlock database." - ) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index aee5dec0599f34..c046f93cdc0f24 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -19,6 +19,7 @@ Platform.FAN, Platform.NUMBER, Platform.SENSOR, + Platform.SWITCH, Platform.TIME, ] diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index 117fadb502ba39..5cdf0e4787b0c7 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -1,9 +1,9 @@ """Renson ventilation unit buttons.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from _collections_abc import Callable from renson_endura_delta.renson import RensonVentilation from homeassistant.components.button import ( diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index a60adccade57b5..e6bd2717981dcd 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -5,7 +5,12 @@ import math from typing import Any -from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType +from renson_endura_delta.field_enum import ( + BREEZE_LEVEL_FIELD, + BREEZE_TEMPERATURE_FIELD, + CURRENT_LEVEL_FIELD, + DataType, +) from renson_endura_delta.renson import Level, RensonVentilation import voluptuous as vol @@ -38,6 +43,7 @@ SPEED_MAPPING = { Level.OFF.value: 0, Level.HOLIDAY.value: 0, + Level.BREEZE.value: 0, Level.LEVEL1.value: 1, Level.LEVEL2.value: 2, Level.LEVEL3.value: 3, @@ -129,6 +135,21 @@ def _handle_coordinator_update(self) -> None: DataType.LEVEL, ) + if level == Level.BREEZE: + level = self.api.parse_value( + self.api.get_field_value( + self.coordinator.data, BREEZE_LEVEL_FIELD.name + ), + DataType.LEVEL, + ) + else: + level = self.api.parse_value( + self.api.get_field_value( + self.coordinator.data, CURRENT_LEVEL_FIELD.name + ), + DataType.LEVEL, + ) + self._attr_percentage = ranged_value_to_percentage( SPEED_RANGE, SPEED_MAPPING[level] ) @@ -155,13 +176,25 @@ async def async_set_percentage(self, percentage: int) -> None: """Set fan speed percentage.""" _LOGGER.debug("Changing fan speed percentage to %s", percentage) + level = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, CURRENT_LEVEL_FIELD.name), + DataType.LEVEL, + ) + if percentage == 0: cmd = Level.HOLIDAY else: speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) cmd = CMD_MAPPING[speed] - await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) + if level == Level.BREEZE.value: + all_data = self.coordinator.data + breeze_temp = self.api.get_field_value(all_data, BREEZE_TEMPERATURE_FIELD) + await self.hass.async_add_executor_job( + self.api.set_breeze, cmd.name, breeze_temp, True + ) + else: + await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 801c25e6ab2b14..367b4a47a63ce9 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -172,14 +172,12 @@ class RensonSensorEntityDescription( raw_format=False, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - entity_registry_enabled_default=False, ), RensonSensorEntityDescription( key="BREEZE_LEVEL_FIELD", translation_key="breeze_level", field=BREEZE_LEVEL_FIELD, raw_format=False, - entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, options=["off", "level1", "level2", "level3", "level4", "breeze"], ), diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index da385ef07bde7f..b756d16ea796ae 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -60,6 +60,11 @@ "name": "Preheater" } }, + "switch": { + "breeze": { + "name": "Breeze" + } + }, "sensor": { "co2_quality_category": { "name": "CO2 quality category", diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py new file mode 100644 index 00000000000000..a724dcc5530216 --- /dev/null +++ b/homeassistant/components/renson/switch.py @@ -0,0 +1,80 @@ +"""Breeze switch of the Renson ventilation unit.""" +from __future__ import annotations + +import logging +from typing import Any + +from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType +from renson_endura_delta.renson import Level, RensonVentilation + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + + +class RensonBreezeSwitch(RensonEntity, SwitchEntity): + """Provide the breeze switch.""" + + _attr_icon = "mdi:weather-dust" + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_has_entity_name = True + _attr_translation_key = "breeze" + + def __init__( + self, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__("breeze", api, coordinator) + + self._attr_is_on = False + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + _LOGGER.debug("Enable Breeze") + + await self.hass.async_add_executor_job(self.api.set_manual_level, Level.BREEZE) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + _LOGGER.debug("Disable Breeze") + + await self.hass.async_add_executor_job(self.api.set_manual_level, Level.OFF) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + level = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, CURRENT_LEVEL_FIELD.name), + DataType.LEVEL, + ) + + self._attr_is_on = level == Level.BREEZE.value + + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Call the Renson integration to setup.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonBreezeSwitch(api, coordinator)]) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index bb8c9427a9c4a0..3196dbf3ad793c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -84,6 +84,8 @@ async def async_device_config_update() -> None: async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: await host.update_states() + except CredentialsInvalidError as err: + raise ConfigEntryAuthFailed(err) from err except ReolinkError as err: raise UpdateFailed(str(err)) from err diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 0f2ef19ba8733f..81d11e2fd0a979 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.8"] + "requirements": ["reolink-aio==0.8.9"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 92e9a6164f8649..fb4d42bb97de89 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -367,7 +367,8 @@ "state": { "stayoff": "Stay off", "auto": "Auto", - "alwaysonatnight": "Auto & always on at night" + "alwaysonatnight": "Auto & always on at night", + "alwayson": "Always on" } } }, diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 54b1f249ca63da..f2ce3bac84e464 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -105,7 +105,8 @@ async def async_process_repairs_platforms(hass: HomeAssistant) -> None: await async_process_integration_platforms(hass, DOMAIN, _register_repairs_platform) -async def _register_repairs_platform( +@callback +def _register_repairs_platform( hass: HomeAssistant, integration_domain: str, platform: RepairsProtocol ) -> None: """Register a repairs platform.""" diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 7dbe295afee18c..e021b72ff3d947 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -1,7 +1,6 @@ """Support for RESTful switches.""" from __future__ import annotations -import asyncio from http import HTTPStatus import logging from typing import Any @@ -117,7 +116,7 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, httpx.RequestError) as exc: + except (TimeoutError, httpx.RequestError) as exc: raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc @@ -177,7 +176,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: _LOGGER.error( "Can't turn on %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, httpx.RequestError): + except (TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching on %s", self._resource) async def async_turn_off(self, **kwargs: Any) -> None: @@ -192,7 +191,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.error( "Can't turn off %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, httpx.RequestError): + except (TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching off %s", self._resource) async def set_device_state(self, body: Any) -> httpx.Response: @@ -217,7 +216,7 @@ async def async_update(self) -> None: req = None try: req = await self.get_device_state(self.hass) - except (asyncio.TimeoutError, httpx.TimeoutException): + except (TimeoutError, httpx.TimeoutException): _LOGGER.exception("Timed out while fetching data") except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c99df16170ba61..199186cf222529 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -1,7 +1,6 @@ """Support for exposing regular REST commands as services.""" from __future__ import annotations -import asyncio from http import HTTPStatus from json.decoder import JSONDecodeError import logging @@ -188,7 +187,7 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: ) from err return {"content": _content, "status": response.status} - except asyncio.TimeoutError as err: + except TimeoutError as err: raise HomeAssistantError( f"Timeout when calling resource '{request_url}'", translation_domain=DOMAIN, diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 42b6d9a3ecf7b4..5b90e65691143a 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -285,7 +285,7 @@ async def connect(): except ( SerialException, OSError, - asyncio.TimeoutError, + TimeoutError, ): reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] _LOGGER.exception( diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 0d0cf218cd077c..7917fa0bdedfe7 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.65"] + "requirements": ["rflink==0.0.66"] } diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index ffbc3d26421c7c..0f3988442c7696 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -1,7 +1,6 @@ """Support for RFXtrx devices.""" from __future__ import annotations -import asyncio import binascii from collections.abc import Callable, Mapping import copy @@ -23,6 +22,7 @@ Platform, ) from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -49,6 +49,7 @@ DEFAULT_OFF_DELAY = 2.0 SIGNAL_EVENT = f"{DOMAIN}_event" +CONNECT_TIMEOUT = 30.0 _Ts = TypeVarTuple("_Ts") @@ -89,15 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the RFXtrx component.""" hass.data.setdefault(DOMAIN, {}) - try: - await async_setup_internal(hass, entry) - except asyncio.TimeoutError: - # Library currently doesn't support reload - _LOGGER.error( - "Connection timeout: failed to receive response from RFXtrx device" - ) - return False - + await async_setup_internal(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -118,7 +111,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _create_rfx(config: Mapping[str, Any]) -> rfxtrxmod.Connect: +def _create_rfx( + config: Mapping[str, Any], event_callback: Callable[[rfxtrxmod.RFXtrxEvent], None] +) -> rfxtrxmod.Connect: """Construct a rfx object based on config.""" modes = config.get(CONF_PROTOCOLS) @@ -130,18 +125,22 @@ def _create_rfx(config: Mapping[str, Any]) -> rfxtrxmod.Connect: if config[CONF_PORT] is not None: # If port is set then we create a TCP connection - rfx = rfxtrxmod.Connect( - (config[CONF_HOST], config[CONF_PORT]), - None, - transport_protocol=rfxtrxmod.PyNetworkTransport, - modes=modes, - ) + transport = rfxtrxmod.PyNetworkTransport((config[CONF_HOST], config[CONF_PORT])) else: - rfx = rfxtrxmod.Connect( - config[CONF_DEVICE], - None, - modes=modes, - ) + transport = rfxtrxmod.PySerialTransport(config[CONF_DEVICE]) + + rfx = rfxtrxmod.Connect( + transport, + event_callback, + modes=modes, + ) + + try: + rfx.connect(CONNECT_TIMEOUT) + except TimeoutError as exc: + raise ConfigEntryNotReady("Timeout on connect") from exc + except rfxtrxmod.RFXtrxTransportError as exc: + raise ConfigEntryNotReady(str(exc)) from exc return rfx @@ -165,10 +164,6 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up the RFXtrx component.""" config = entry.data - # Initialize library - async with asyncio.timeout(30): - rfx_object = await hass.async_add_executor_job(_create_rfx, config) - # Setup some per device config devices = _get_device_lookup(config[CONF_DEVICES]) pt2262_devices: set[str] = set() @@ -179,8 +174,16 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: @callback def async_handle_receive(event: rfxtrxmod.RFXtrxEvent) -> None: """Handle received messages from RFXtrx gateway.""" - # Log RFXCOM event - if not event.device.id_string: + + if isinstance(event, rfxtrxmod.ConnectionLost): + _LOGGER.warning("Connection was lost, triggering reload") + hass.async_create_task( + hass.config_entries.async_reload(entry.entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + return + + if not event.device or not event.device.id_string: return event_data = { @@ -264,6 +267,13 @@ def _updated_device(event: Event) -> None: if device_id: _remove_device(device_id) + # Initialize library + rfx_object = await hass.async_add_executor_job( + _create_rfx, config, lambda event: hass.add_job(async_handle_receive, event) + ) + + hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object + entry.async_on_unload( hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, _updated_device) ) @@ -275,9 +285,6 @@ def _shutdown_rfxtrx(event: Event) -> None: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) ) - hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object - - rfx_object.event_callback = lambda event: hass.add_job(async_handle_receive, event) def send(call: ServiceCall) -> None: event = call.data[ATTR_EVENT] diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 12b9290af99f64..8f6ff45840cc39 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -372,7 +372,7 @@ def _handle_state_removed( entity_registry.async_remove(entry.entity_id) # Wait for entities to finish cleanup - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() @@ -407,7 +407,7 @@ def _handle_state_added( ) # Wait for entities to finish renaming - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): await wait_for_entities.wait() remove_track_state_changes() @@ -634,22 +634,14 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: def _test_transport(host: str | None, port: int | None, device: str | None) -> bool: """Construct a rfx object based on config.""" if port is not None: - try: - conn = rfxtrxmod.PyNetworkTransport((host, port)) - except OSError: - return False - - conn.close() + conn = rfxtrxmod.PyNetworkTransport((host, port)) else: - try: - conn = rfxtrxmod.PySerialTransport(device) - except serial.SerialException: - return False - - if conn.serial is None: - return False + conn = rfxtrxmod.PySerialTransport(device) - conn.close() + try: + conn.connect() + except (rfxtrxmod.RFXtrxTransportError, TimeoutError): + return False return True diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index 1e2a3d6da27e69..ec902855f2725d 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "iot_class": "local_push", "loggers": ["RFXtrx"], - "requirements": ["pyRFXtrx==0.30.1"] + "requirements": ["pyRFXtrx==0.31.0"] } diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 1b0a83f1c058b4..53575e79c45e0e 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -42,7 +42,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 1 -> 2: Update unique ID of existing, single sensor entity to be consistent with # common format for platforms going forward: if version == 1: - version = entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(entry, version=version) @callback def migrate_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 5b6412caffa257..943b1c628bf1b4 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -77,7 +77,7 @@ async def _async_update_data(self): try: history_task = None async with TaskGroup() as tg: - if hasattr(device, "history"): + if device.has_capability("history"): history_task = tg.create_task( _call_api( self.hass, @@ -96,7 +96,7 @@ async def _async_update_data(self): if history_task: data[device.id].history = history_task.result() except ExceptionGroup as eg: - raise eg.exceptions[0] + raise eg.exceptions[0] # noqa: B904 return data diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 356eb1c2b9b133..32382a2f9299fa 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -10,6 +10,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -162,6 +163,7 @@ class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin category=["doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, cls=RingSensor, ), diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 6b4ad1a5c4a314..ebe8f34c8921a9 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -267,10 +267,11 @@ async def async_update(self): if not dest_found: continue - elif self._lines and journey["number"] not in self._lines: - continue - - elif journey["minutes"] < self._time_offset: + elif ( + self._lines + and journey["number"] not in self._lines + or journey["minutes"] < self._time_offset + ): continue for attr in ("direction", "departure_time", "product", "minutes"): diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index a5c896f3740945..f4293213c0052f 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -123,9 +123,14 @@ async def setup_device( # Verify we can communicate locally - if we can't, switch to cloud api await coordinator.verify_api() coordinator.api.is_available = True + try: + await coordinator.get_maps() + except RoborockException as err: + _LOGGER.warning("Failed to get map data") + _LOGGER.debug(err) try: await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady: + except ConfigEntryNotReady as ex: await coordinator.release() if isinstance(coordinator.api, RoborockMqttClient): _LOGGER.warning( @@ -138,7 +143,7 @@ async def setup_device( # but in case if it isn't, the error can be included in debug logs for the user to grab. if coordinator.last_exception: _LOGGER.debug(coordinator.last_exception) - raise coordinator.last_exception + raise coordinator.last_exception from ex elif coordinator.last_exception: # If this is reached, we have verified that we can communicate with the Vacuum locally, # so if there is an error here - it is not a communication issue but some other problem @@ -149,7 +154,7 @@ async def setup_device( device.name, extra_error, ) - raise coordinator.last_exception + raise coordinator.last_exception from ex return coordinator diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index d0ed508df4c2d8..7154a36f7b8ca8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -59,6 +59,8 @@ def __init__( if mac := self.roborock_device_info.network_info.mac: self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)} + # Maps from map flag to map name + self.maps: dict[int, str] = {} async def verify_api(self) -> None: """Verify that the api is reachable. If it is not, switch clients.""" @@ -108,3 +110,10 @@ def _set_current_map(self) -> None: self.current_map = ( self.roborock_device_info.props.status.map_status - 3 ) // 4 + + async def get_maps(self) -> None: + """Add a map to the coordinators mapping.""" + maps = await self.api.get_multi_maps_list() + if maps and maps.map_info: + for roborock_map in maps.map_info: + self.maps[roborock_map.mapFlag] = roborock_map.name diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index b2a14b57819234..669572326798ab 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -66,13 +66,7 @@ def __init__( self._attr_image_last_updated = dt_util.utcnow() self.map_flag = map_flag self.cached_map = self._create_image(starting_map) - - @property - def entity_category(self) -> EntityCategory | None: - """Return diagnostic entity category for any non-selected maps.""" - if not self.is_selected: - return EntityCategory.DIAGNOSTIC - return None + self._attr_entity_category = EntityCategory.DIAGNOSTIC @property def is_selected(self) -> bool: @@ -127,42 +121,37 @@ async def create_coordinator_maps( Only one map can be loaded at a time per device. """ entities = [] - maps = await coord.cloud_api.get_multi_maps_list() - if maps is not None and maps.map_info is not None: - cur_map = coord.current_map - # This won't be None at this point as the coordinator will have run first. - assert cur_map is not None - # Sort the maps so that we start with the current map and we can skip the - # load_multi_map call. - maps_info = sorted( - maps.map_info, key=lambda data: data.mapFlag == cur_map, reverse=True - ) - for roborock_map in maps_info: - # Load the map - so we can access it with get_map_v1 - if roborock_map.mapFlag != cur_map: - # Only change the map and sleep if we have multiple maps. - await coord.api.send_command( - RoborockCommand.LOAD_MULTI_MAP, [roborock_map.mapFlag] - ) - # We cannot get the map until the roborock servers fully process the - # map change. - await asyncio.sleep(MAP_SLEEP) - # Get the map data - api_data: bytes = await coord.cloud_api.get_map_v1() - entities.append( - RoborockMap( - f"{slugify(coord.roborock_device_info.device.duid)}_map_{roborock_map.name}", - coord, - roborock_map.mapFlag, - api_data, - roborock_map.name, - ) - ) - if len(maps.map_info) != 1: - # Set the map back to the map the user previously had selected so that it - # does not change the end user's app. - # Only needs to happen when we changed maps above. - await coord.cloud_api.send_command( - RoborockCommand.LOAD_MULTI_MAP, [cur_map] + + cur_map = coord.current_map + # This won't be None at this point as the coordinator will have run first. + assert cur_map is not None + # Sort the maps so that we start with the current map and we can skip the + # load_multi_map call. + maps_info = sorted( + coord.maps.items(), key=lambda data: data[0] == cur_map, reverse=True + ) + for map_flag, map_name in maps_info: + # Load the map - so we can access it with get_map_v1 + if map_flag != cur_map: + # Only change the map and sleep if we have multiple maps. + await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag]) + # We cannot get the map until the roborock servers fully process the + # map change. + await asyncio.sleep(MAP_SLEEP) + # Get the map data + api_data: bytes = await coord.cloud_api.get_map_v1() + entities.append( + RoborockMap( + f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_name}", + coord, + map_flag, + api_data, + map_name, ) + ) + if len(coord.maps) != 1: + # Set the map back to the map the user previously had selected so that it + # does not change the end user's app. + # Only needs to happen when we changed maps above. + await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map]) return entities diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index ddb65c3187c077..a7a7fe01d23ca5 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.39.1", + "python-roborock==0.40.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 586e2a5f062484..bd302e16a90079 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -90,7 +90,7 @@ async def async_connect_or_timeout( except RoombaConnectionError as err: _LOGGER.debug("Error to connect to vacuum: %s", err) raise CannotConnect from err - except asyncio.TimeoutError as err: + except TimeoutError as err: # api looping if user or password incorrect and roomba exist await async_disconnect_or_timeout(hass, roomba) _LOGGER.debug("Timeout expired: %s", err) @@ -102,7 +102,7 @@ async def async_connect_or_timeout( async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> None: """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(3): await hass.async_add_executor_job(roomba.disconnect) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index e2876e9f3b475f..530ba8e81377af 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba and Braava", - "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Xitee1"], + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Xitee1", "@Orhideous"], "config_flow": true, "dhcp": [ { @@ -22,6 +22,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/roomba", + "import_executor": true, "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], "requirements": ["roombapy==1.6.13"], diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index afbf0e6b4a7b6b..5fce298a56bc57 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -268,9 +268,10 @@ def update_state(self): break # determine player state if not new_state: - if self.player_data["state"] == "playing": - new_state = MediaPlayerState.PLAYING - elif self.player_data["state"] == "loading": + if ( + self.player_data["state"] == "playing" + or self.player_data["state"] == "loading" + ): new_state = MediaPlayerState.PLAYING elif self.player_data["state"] == "stopped": new_state = MediaPlayerState.IDLE diff --git a/homeassistant/components/rova/manifest.json b/homeassistant/components/rova/manifest.json index b67acb1e512a0a..03c6ddbb12cc6e 100644 --- a/homeassistant/components/rova/manifest.json +++ b/homeassistant/components/rova/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/rova", "iot_class": "cloud_polling", "loggers": ["rova"], - "requirements": ["rova==0.3.0"] + "requirements": ["rova==0.4.0"] } diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index df5027ebaa8df2..89cc22ef766e0a 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -18,6 +18,7 @@ KEY_SYS_CLIENTS, UNDO_UPDATE_LISTENERS, ) +from .coordinator import RuckusUnleashedDataUpdateCoordinator _LOGGER = logging.getLogger(__package__) @@ -65,14 +66,19 @@ def add_new_entities(coordinator, async_add_entities, tracked): @callback -def restore_entities(registry, coordinator, entry, async_add_entities, tracked): +def restore_entities( + registry: er.EntityRegistry, + coordinator: RuckusUnleashedDataUpdateCoordinator, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: """Restore clients that are not a part of active clients list.""" - missing = [] + missing: list[RuckusUnleashedDevice] = [] - for entity in registry.entities.values(): + for entity in registry.entities.get_entries_for_config_entry_id(entry.entry_id): if ( - entity.config_entry_id == entry.entry_id - and entity.platform == DOMAIN + entity.platform == DOMAIN and entity.unique_id not in coordinator.data[KEY_SYS_CLIENTS] ): missing.append( diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py index f07929c0ab46a0..592c82adc683ca 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -33,7 +33,12 @@ def __init__(self, hass: HomeAssistant, rympro: RymPro) -> None: async def _async_update_data(self) -> dict[int, dict]: """Fetch data from Rym Pro.""" try: - return await self.rympro.last_read() + meters = await self.rympro.last_read() + for meter_id, meter in meters.items(): + meter["consumption_forecast"] = await self.rympro.consumption_forecast( + meter_id + ) + return meters except UnauthorizedError as error: assert self.config_entry await self.hass.config_entries.async_reload(self.config_entry.entry_id) diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 35e4b155b28225..a6b5b8df93d94b 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -1,9 +1,12 @@ """Sensor for RymPro meters.""" from __future__ import annotations +from dataclasses import dataclass + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -17,6 +20,30 @@ from .coordinator import RymProDataUpdateCoordinator +@dataclass(kw_only=True, frozen=True) +class RymProSensorEntityDescription(SensorEntityDescription): + """Class describing RymPro sensor entities.""" + + value_key: str + + +SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = ( + RymProSensorEntityDescription( + key="total_consumption", + translation_key="total_consumption", + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=3, + value_key="read", + ), + RymProSensorEntityDescription( + key="monthly_forecast", + translation_key="monthly_forecast", + suggested_display_precision=3, + value_key="consumption_forecast", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -25,8 +52,9 @@ async def async_setup_entry( """Set up sensors for device.""" coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - RymProSensor(coordinator, meter_id, meter["read"], config_entry.entry_id) + RymProSensor(coordinator, meter_id, description, config_entry.entry_id) for meter_id, meter in coordinator.data.items() + for description in SENSOR_DESCRIPTIONS ) @@ -34,32 +62,31 @@ class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity) """Sensor for RymPro meters.""" _attr_has_entity_name = True - _attr_translation_key = "total_consumption" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS - _attr_state_class = SensorStateClass.TOTAL_INCREASING + entity_description: RymProSensorEntityDescription def __init__( self, coordinator: RymProDataUpdateCoordinator, meter_id: int, - last_read: int, + description: RymProSensorEntityDescription, entry_id: str, ) -> None: """Initialize sensor.""" super().__init__(coordinator) self._meter_id = meter_id unique_id = f"{entry_id}_{meter_id}" - self._attr_unique_id = f"{unique_id}_total_consumption" + self._attr_unique_id = f"{unique_id}_{description.key}" self._attr_extra_state_attributes = {"meter_id": str(meter_id)} self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, manufacturer="Read Your Meter Pro", name=f"Meter {meter_id}", ) - self._attr_native_value = last_read + self.entity_description = description @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.coordinator.data[self._meter_id]["read"] + return self.coordinator.data[self._meter_id][self.entity_description.value_key] diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index 2909d6c1b9b848..c58bf5b93babd6 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -21,6 +21,9 @@ "sensor": { "total_consumption": { "name": "Total consumption" + }, + "monthly_forecast": { + "name": "Monthly forecast" } } } diff --git a/homeassistant/components/samsam/__init__.py b/homeassistant/components/samsam/__init__.py new file mode 100644 index 00000000000000..a7109c35339e19 --- /dev/null +++ b/homeassistant/components/samsam/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: SamSam.""" diff --git a/homeassistant/components/samsam/manifest.json b/homeassistant/components/samsam/manifest.json new file mode 100644 index 00000000000000..61078e6c432bf4 --- /dev/null +++ b/homeassistant/components/samsam/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "samsam", + "name": "SamSam", + "integration_type": "virtual", + "supported_by": "energyzero" +} diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 2ced868ada7e79..56fd230fd6f50f 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -93,9 +93,10 @@ async def async_call(self, hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.debug("Calling debouncer to get a reload after cooldown") await self._debounced_reload.async_call() - async def async_shutdown(self) -> None: + @callback + def async_shutdown(self) -> None: """Cancel any pending reload.""" - await self._debounced_reload.async_shutdown() + self._debounced_reload.async_shutdown() async def _async_reload_entry(self) -> None: """Reload entry.""" @@ -198,10 +199,13 @@ async def _async_create_bridge_with_updated_data( mac: str | None = entry.data.get(CONF_MAC) model: str | None = entry.data.get(CONF_MODEL) - if (not mac or not model) and not load_info_attempted: + mac_is_incorrectly_formatted = mac and dr.format_mac(mac) != mac + if ( + not mac or not model or mac_is_incorrectly_formatted + ) and not load_info_attempted: info = await bridge.async_device_info() - if not mac: + if not mac or mac_is_incorrectly_formatted: LOGGER.debug("Attempting to get mac for %s", host) if info: mac = mac_from_device_info(info) @@ -215,7 +219,7 @@ async def _async_create_bridge_with_updated_data( # Samsung sometimes returns a value of "none" for the mac address # this should be ignored LOGGER.info("Updated mac to %s for %s", mac, host) - updated_data[CONF_MAC] = mac + updated_data[CONF_MAC] = dr.format_mac(mac) else: LOGGER.info("Failed to get mac for %s", host) @@ -269,7 +273,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> en_reg = er.async_get(hass) en_reg.async_clear_config_entry(config_entry.entry_id) - version = config_entry.version = 2 + version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) + LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 2e6f64f08e1102..e7f71210dfebff 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -80,6 +80,17 @@ def _entry_is_complete( ) +def _mac_is_same_with_incorrect_formatting( + current_unformatted_mac: str, formatted_mac: str +) -> bool: + """Check if two macs are the same but formatted incorrectly.""" + current_formatted_mac = format_mac(current_unformatted_mac) + return ( + current_formatted_mac == formatted_mac + and current_unformatted_mac != current_formatted_mac + ) + + class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Samsung TV config flow.""" @@ -359,7 +370,10 @@ def _async_update_existing_matching_entry( and data.get(CONF_SSDP_MAIN_TV_AGENT_LOCATION) != self._ssdp_main_tv_agent_location ) - update_mac = self._mac and not data.get(CONF_MAC) + update_mac = self._mac and ( + not (data_mac := data.get(CONF_MAC)) + or _mac_is_same_with_incorrect_formatting(data_mac, self._mac) + ) update_model = self._model and not data.get(CONF_MODEL) if ( update_ssdp_rendering_control_location @@ -464,7 +478,7 @@ async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowRes async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) - self._mac = discovery_info.macaddress + self._mac = format_mac(discovery_info.macaddress) self._host = discovery_info.ip self._async_start_discovery_with_mac_address() await self._async_set_device_unique_id() diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 00b8fec8e6acb4..63a78925a6ea1b 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -31,6 +31,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/samsungtv", + "import_executor": true, "integration_type": "device", "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 14589274da6234..44fce7f953f288 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -219,7 +219,7 @@ async def _async_startup_app_list(self) -> None: try: async with asyncio.timeout(APP_LIST_DELAY): await self._app_list_event.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: # No need to try again self._app_list_event.set() LOGGER.debug("Failed to load app list from %s: %r", self._host, err) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 72d5ad54565040..23b36ddae0b698 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.12.1"] + "requirements": ["pyschlage==2024.2.0"] } diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index ade210b304ac95..f39f662de3e567 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.2", "lxml==5.1.0"] + "requirements": ["beautifulsoup4==4.12.3", "lxml==5.1.0"] } diff --git a/homeassistant/components/screenaway/__init__.py b/homeassistant/components/screenaway/__init__.py new file mode 100644 index 00000000000000..c59e133fc24eb8 --- /dev/null +++ b/homeassistant/components/screenaway/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ScreenAway.""" diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index cfe1a12a24f8d0..3ad35ff345d4d4 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -1,6 +1,5 @@ """Constants for monitoring a Sense energy sensor.""" -import asyncio import socket from sense_energy import ( @@ -39,11 +38,11 @@ SOLAR_POWERED_NAME = "Solar Powered Percentage" SOLAR_POWERED_ID = "solar_powered" -SENSE_TIMEOUT_EXCEPTIONS = (asyncio.TimeoutError, SenseAPITimeoutException) +SENSE_TIMEOUT_EXCEPTIONS = (TimeoutError, SenseAPITimeoutException) SENSE_WEBSOCKET_EXCEPTIONS = (socket.gaierror, SenseWebsocketException) SENSE_CONNECT_EXCEPTIONS = ( socket.gaierror, - asyncio.TimeoutError, + TimeoutError, SenseAPITimeoutException, SenseAPIException, ) diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 5440372cbc810e..1adfe3ecbd3314 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -300,7 +300,9 @@ def native_value(self): @property def last_reset(self): """Return the time when the sensor was last reset, if any.""" - return self._data.trend_start(self._sensor_type) + if self._attr_state_class == SensorStateClass.TOTAL: + return self._data.trend_start(self._sensor_type) + return None class SenseEnergyDevice(SensorEntity): diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 923bc3eae1f8fa..9a278d0c4dfc33 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -47,12 +47,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (AuthenticationError, ConnectionError, NoDevicesError, NoUsernameError): return False - entry.version = 2 - LOGGER.debug("Migrate Sensibo config entry unique id to %s", new_unique_id) hass.config_entries.async_update_entry( entry, unique_id=new_unique_id, + version=2, ) return True diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index b7d4bca890e5e4..d826e854fa080a 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -57,15 +57,13 @@ async def async_step_reauth_confirm( assert self.entry is not None if username == self.entry.unique_id: - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( self.entry, data={ **self.entry.data, CONF_API_KEY: api_key, }, ) - await self.hass.config_entries.async_reload(self.entry.entry_id) - return self.async_abort(reason="reauth_successful") errors["base"] = "incorrect_api_key" return self.async_show_form( diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index d6dbe957deff05..0b5f151c49f415 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -1,6 +1,5 @@ """Constants for Sensibo.""" -import asyncio import logging from aiohttp.client_exceptions import ClientConnectionError @@ -27,7 +26,7 @@ SENSIBO_ERRORS = ( ClientConnectionError, - asyncio.TimeoutError, + TimeoutError, AuthenticationError, SensiboError, ) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 05fec64608f71b..9f525c3d498022 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -15,8 +15,6 @@ from typing_extensions import override from homeassistant.config_entries import ConfigEntry - -# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 _DEPRECATED_DEVICE_CLASS_AQI, _DEPRECATED_DEVICE_CLASS_BATTERY, @@ -502,6 +500,7 @@ def suggested_unit_of_measurement(self) -> str | None: Note: suggested_unit_of_measurement is stored in the entity registry the first time the entity is seen, and then never updated. + """ if hasattr(self, "_attr_suggested_unit_of_measurement"): return self._attr_suggested_unit_of_measurement diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aad882821d6959..3dc8f878791acc 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -640,7 +640,11 @@ class SensorStateClass(StrEnum): SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, }, - SensorDeviceClass.WEIGHT: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.WEIGHT: { + SensorStateClass.MEASUREMENT, + SensorStateClass.TOTAL, + SensorStateClass.TOTAL_INCREASING, + }, SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT}, } diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 9a0ecbeb9a505f..a53ae906718584 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -148,36 +148,28 @@ def _equivalent_units(units: set[str | None]) -> bool: if len(units) == 1: return True units = { - EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit for unit in units + EQUIVALENT_UNITS[unit] if unit in EQUIVALENT_UNITS else unit # noqa: SIM401 + for unit in units } return len(units) == 1 -def _parse_float(state: str) -> float: - """Parse a float string, throw on inf or nan.""" - fstate = float(state) - if not math.isfinite(fstate): - raise ValueError - 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 - ] + float_states: list[tuple[float, State]] = [] + append = float_states.append + isfinite = math.isfinite + for state in entity_history: + try: + if (float_state := float(state.state)) is not None and isfinite( + float_state + ): + append((float_state, state)) + except (ValueError, TypeError): + pass + return float_states def _normalize_states( @@ -231,13 +223,14 @@ def _normalize_states( converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[statistics_unit] valid_fstates: list[tuple[float, State]] = [] - convert: Callable[[float], float] + convert: Callable[[float], float] | None = None last_unit: str | None | object = object() + valid_units = converter.VALID_UNITS for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude states with unsupported unit from statistics - if state_unit not in converter.VALID_UNITS: + if state_unit not in valid_units: if WARN_UNSUPPORTED_UNIT not in hass.data: hass.data[WARN_UNSUPPORTED_UNIT] = set() if entity_id not in hass.data[WARN_UNSUPPORTED_UNIT]: @@ -256,13 +249,20 @@ def _normalize_states( LINK_DEV_STATISTICS, ) continue + if state_unit != last_unit: # The unit of measurement has changed since the last state change # recreate the converter factory - convert = converter.converter_factory(state_unit, statistics_unit) + if state_unit == statistics_unit: + convert = None + else: + convert = converter.converter_factory(state_unit, statistics_unit) last_unit = state_unit - valid_fstates.append((convert(fstate), state)) + if convert is not None: + fstate = convert(fstate) + + valid_fstates.append((fstate, state)) return statistics_unit, valid_fstates diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 3c3eaeb78e31a0..425225e07efec8 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.39.2"] + "requirements": ["sentry-sdk==1.40.3"] } diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 9cc3c8ffd57707..dd61e1627b4588 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -21,6 +21,7 @@ from homeassistant.helpers import ( aiohttp_client, config_validation as cv, + entity, entity_registry as er, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -293,6 +294,7 @@ def __init__( async def _async_update(self): """Get updated data from 17track.net.""" + entities: list[entity.Entity] = [] try: packages = await self._client.profile.packages( @@ -306,12 +308,9 @@ async def _async_update(self): _LOGGER.debug("Will add new tracking numbers: %s", to_add) if to_add: - self._async_add_entities( - [ - SeventeenTrackPackageSensor(self, new_packages[tracking_number]) - for tracking_number in to_add - ], - True, + entities.extend( + SeventeenTrackPackageSensor(self, new_packages[tracking_number]) + for tracking_number in to_add ) self.packages = new_packages @@ -327,15 +326,13 @@ async def _async_update(self): # creating summary sensors on first update if self.first_update: self.first_update = False - - self._async_add_entities( - [ - SeventeenTrackSummarySensor(self, status, quantity) - for status, quantity in self.summary.items() - ], - True, + entities.extend( + SeventeenTrackSummarySensor(self, status, quantity) + for status, quantity in self.summary.items() ) except SeventeenTrackError as err: _LOGGER.error("There was an error retrieving the summary: %s", err) self.summary = {} + + self._async_add_entities(entities, True) diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index f80e7acf9a6e12..53a8c4cba3d49e 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -40,7 +40,7 @@ async def async_connect_or_timeout(ayla_api: AylaApi) -> bool: except SharkIqAuthError: LOGGER.error("Authentication error connecting to Shark IQ api") return False - except asyncio.TimeoutError as exc: + except TimeoutError as exc: LOGGER.error("Timeout expired") raise CannotConnect from exc diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 1957d12048fc6f..c0ca5e1b9e554a 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -53,7 +53,7 @@ async def _validate_input( async with asyncio.timeout(10): LOGGER.debug("Initialize connection to Ayla networks API") await ayla_api.async_sign_in() - except (asyncio.TimeoutError, aiohttp.ClientError, TypeError) as error: + except (TimeoutError, aiohttp.ClientError, TypeError) as error: LOGGER.error(error) raise CannotConnect( "Unable to connect to SharkIQ services. Check your region settings." diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index e1330b06c089c9..c378797f56e2fa 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -60,10 +60,10 @@ async def _async_update_vacuum(sharkiq: SharkIqVacuum) -> None: async def _async_update_data(self) -> bool: """Update data device by device.""" try: - if self.ayla_api.token_expiring_soon: - await self.ayla_api.async_refresh_auth() - elif datetime.now() > self.ayla_api.auth_expiration - timedelta( - seconds=600 + if ( + self.ayla_api.token_expiring_soon + or datetime.now() + > self.ayla_api.auth_expiration - timedelta(seconds=600) ): await self.ayla_api.async_refresh_auth() diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 67258d701e96a8..5aa8dadee19890 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -90,7 +90,7 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: try: async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT ) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 142b5f9c521b40..4895e2a1a2b869 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -208,7 +208,7 @@ def _async_device_online(_: Any, update_type: BlockUpdateType) -> None: data["model"] = device.settings["device"]["type"] hass.config_entries.async_update_entry(entry, data=data) - hass.async_create_task(_async_block_device_setup()) + hass.async_create_task(_async_block_device_setup(), eager_start=True) if sleep_period == 0: # Not a sleeping device, finish setup @@ -298,7 +298,7 @@ def _async_device_online(_: Any, update_type: RpcUpdateType) -> None: data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) - hass.async_create_task(_async_rpc_device_setup()) + hass.async_create_task(_async_rpc_device_setup(), eager_start=True) if sleep_period == 0: # Not a sleeping device, finish setup diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 17f60f566aa8d6..f4294dee9ee9cb 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar from aioshelly.const import RPC_GENERATIONS @@ -57,7 +58,7 @@ class ShellyButtonDescription( ShellyButtonDescription[ShellyBlockCoordinator]( key="self_test", name="Self test", - icon="mdi:progress-wrench", + translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -65,7 +66,7 @@ class ShellyButtonDescription( ShellyButtonDescription[ShellyBlockCoordinator]( key="mute", name="Mute", - icon="mdi:volume-mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -73,7 +74,7 @@ class ShellyButtonDescription( ShellyButtonDescription[ShellyBlockCoordinator]( key="unmute", name="Unmute", - icon="mdi:volume-high", + translation_key="unmute", entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(), supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, @@ -83,8 +84,8 @@ class ShellyButtonDescription( @callback def async_migrate_unique_ids( - entity_entry: er.RegistryEntry, coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator, + entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Migrate button unique IDs.""" if not entity_entry.entity_id.startswith("button"): @@ -117,35 +118,25 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - - @callback - def _async_migrate_unique_ids( - entity_entry: er.RegistryEntry, - ) -> dict[str, Any] | None: - """Migrate button unique IDs.""" - if TYPE_CHECKING: - assert coordinator is not None - return async_migrate_unique_ids(entity_entry, coordinator) - - coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None + entry_data = get_entry_data(hass)[config_entry.entry_id] + coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None if get_device_entry_gen(config_entry) in RPC_GENERATIONS: - coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + coordinator = entry_data.rpc else: - coordinator = get_entry_data(hass)[config_entry.entry_id].block - - if coordinator is not None: - await er.async_migrate_entries( - hass, config_entry.entry_id, _async_migrate_unique_ids - ) + coordinator = entry_data.block - entities: list[ShellyButton] = [] + if TYPE_CHECKING: + assert coordinator is not None - for button in BUTTONS: - if not button.supported(coordinator): - continue - entities.append(ShellyButton(coordinator, button)) + await er.async_migrate_entries( + hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) + ) - async_add_entities(entities) + async_add_entities( + ShellyButton(coordinator, button) + for button in BUTTONS + if button.supported(coordinator) + ) class ShellyButton( diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 59343ca6d2f941..3ceb38c84c3ef5 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -43,7 +43,12 @@ ) from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ShellyRpcEntity -from .utils import async_remove_shelly_entity, get_device_entry_gen, get_rpc_key_ids +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_key_ids, + is_rpc_thermostat_internal_actuator, +) async def async_setup_entry( @@ -127,7 +132,7 @@ def async_setup_rpc_entry( for id_ in climate_key_ids: climate_ids.append(id_) - if coordinator.device.shelly.get("relay_in_thermostat", False): + if is_rpc_thermostat_internal_actuator(coordinator.device.status): # Wall Display relay is used as the thermostat actuator, # we need to remove a switch entity unique_id = f"{coordinator.mac}-switch:{id_}" @@ -156,7 +161,6 @@ class BlockSleepingClimate( """Representation of a Shelly climate device.""" _attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] - _attr_icon = "mdi:thermostat" _attr_max_temp = SHTRV_01_TEMPERATURE_SETTINGS["max"] _attr_min_temp = SHTRV_01_TEMPERATURE_SETTINGS["min"] _attr_supported_features = ( @@ -439,7 +443,6 @@ def _handle_coordinator_update(self) -> None: class RpcClimate(ShellyRpcEntity, ClimateEntity): """Entity that controls a thermostat on RPC based Shelly devices.""" - _attr_icon = "mdi:thermostat" _attr_max_temp = RPC_THERMOSTAT_SETTINGS["max"] _attr_min_temp = RPC_THERMOSTAT_SETTINGS["min"] _attr_supported_features = ( diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 86fd98b527e111..4afe66199f0605 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -279,7 +279,7 @@ def _async_device_updates_handler(self) -> None: self.name, ENTRY_RELOAD_COOLDOWN, ) - self.hass.async_create_task(self._debounced_reload.async_call()) + self._debounced_reload.async_schedule_call() self._last_cfg_changed = cfg_changed async def _async_update_data(self) -> None: @@ -496,7 +496,7 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: self.name, ENTRY_RELOAD_COOLDOWN, ) - self.hass.async_create_task(self._debounced_reload.async_call()) + self._debounced_reload.async_schedule_call() elif event_type in RPC_INPUTS_EVENTS_TYPES: for event_callback in self._input_event_listeners: event_callback(event) @@ -602,10 +602,10 @@ def _async_handle_update( ) -> None: """Handle device update.""" if update_type is RpcUpdateType.INITIALIZED: - self.hass.async_create_task(self._async_connected()) + self.hass.async_create_task(self._async_connected(), eager_start=True) self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.hass.async_create_task(self._async_disconnected()) + self.hass.async_create_task(self._async_disconnected(), eager_start=True) elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: @@ -619,7 +619,7 @@ def async_setup(self) -> None: self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.hass.async_create_task(self._async_connected()) + self.hass.async_create_task(self._async_connected(), eager_start=True) async def shutdown(self) -> None: """Shutdown the coordinator.""" @@ -701,4 +701,4 @@ async def async_reconnect_soon(hass: HomeAssistant, entry: ConfigEntry) -> None: and (entry_data := get_entry_data(hass).get(entry.entry_id)) and (coordinator := entry_data.rpc) ): - hass.async_create_task(coordinator.async_request_refresh()) + hass.async_create_task(coordinator.async_request_refresh(), eager_start=True) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 3dd156e9e3032d..513e2c889985ff 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -67,7 +67,7 @@ def async_setup_block_attribute_entities( sensor_class: Callable, ) -> None: """Set up entities for block attributes.""" - blocks = [] + entities = [] assert coordinator.device.blocks @@ -78,7 +78,7 @@ def async_setup_block_attribute_entities( continue # Filter out non-existing sensors and sensors without a value - if getattr(block, sensor_id, None) in (-1, None): + if getattr(block, sensor_id, None) is None: continue # Filter and remove entities that according to settings @@ -90,17 +90,14 @@ def async_setup_block_attribute_entities( unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" async_remove_shelly_entity(hass, domain, unique_id) else: - blocks.append((block, sensor_id, description)) + entities.append( + sensor_class(coordinator, block, sensor_id, description) + ) - if not blocks: + if not entities: return - async_add_entities( - [ - sensor_class(coordinator, block, sensor_id, description) - for block, sensor_id, description in blocks - ] - ) + async_add_entities(entities) @callback @@ -273,7 +270,6 @@ class BlockEntityDescription(EntityDescription): # restrict the type to str. name: str = "" - icon_fn: Callable[[dict], str] | None = None unit_fn: Callable[[dict], str] | None = None value: Callable[[Any], Any] = lambda val: val available: Callable[[Block], bool] | None = None diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json new file mode 100644 index 00000000000000..1baf61acf3ba80 --- /dev/null +++ b/homeassistant/components/shelly/icons.json @@ -0,0 +1,46 @@ +{ + "entity": { + "button": { + "mute": { + "default": "mdi:volume-mute" + }, + "self_test": { + "default": "mdi:progress-wrench" + }, + "unmute": { + "default": "mdi:volume-high" + } + }, + "number": { + "valve_position": { + "default": "mdi:pipe-valve" + } + }, + "sensor": { + "gas_concentration": { + "default": "mdi:gauge" + }, + "lamp_life": { + "default": "mdi:progress-wrench" + }, + "operation": { + "default": "mdi:cog-transfer" + }, + "tilt": { + "default": "mdi:angle-acute" + }, + "valve_status": { + "default": "mdi:valve" + } + }, + "switch": { + "valve_switch": { + "default": "mdi:valve", + "state": { + "off": "mdi:valve-closed", + "on": "mdi:valve-open" + } + } + } + } +} diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index e08b04d16a383d..0e0f9d7d796379 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -5,11 +5,12 @@ "config_flow": true, "dependencies": ["bluetooth", "http"], "documentation": "https://www.home-assistant.io/integrations/shelly", + "import_executor": true, "integration_type": "device", "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==8.0.1"], + "requirements": ["aioshelly==8.1.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 4cab817e67c64a..ef3963c53c3f9f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -40,7 +40,7 @@ class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): NUMBERS: dict[tuple[str, str], BlockNumberDescription] = { ("device", "valvePos"): BlockNumberDescription( key="device|valvepos", - icon="mdi:pipe-valve", + translation_key="valve_position", name="Valve position", native_unit_of_measurement=PERCENTAGE, available=lambda block: cast(int, block.valveError) != 1, diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index e46800963a3dc4..b88b6886b8480a 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -51,7 +51,11 @@ async_setup_entry_rest, async_setup_entry_rpc, ) -from .utils import get_device_entry_gen, get_device_uptime +from .utils import ( + get_device_entry_gen, + get_device_uptime, + is_rpc_wifi_stations_disabled, +) @dataclass(frozen=True) @@ -235,7 +239,7 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): key="sensor|concentration", name="Gas concentration", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - icon="mdi:gauge", + translation_key="gas_concentration", state_class=SensorStateClass.MEASUREMENT, ), ("sensor", "temp"): BlockSensorDescription( @@ -279,14 +283,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): key="sensor|tilt", name="Tilt", native_unit_of_measurement=DEGREE, - icon="mdi:angle-acute", + translation_key="tilt", state_class=SensorStateClass.MEASUREMENT, ), ("relay", "totalWorkTime"): BlockSensorDescription( key="relay|totalWorkTime", name="Lamp life", native_unit_of_measurement=PERCENTAGE, - icon="mdi:progress-wrench", + translation_key="lamp_life", value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS), suggested_display_precision=1, extra_state_attributes=lambda block: { @@ -308,7 +312,6 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): 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}, ), @@ -316,7 +319,6 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): key="valve|valve", name="Valve status", translation_key="valve_status", - icon="mdi:valve", device_class=SensorDeviceClass.ENUM, options=[ "checking", @@ -907,9 +909,7 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, - removal_condition=lambda config, _status, key: ( - config[key]["sta"]["enable"] is False - ), + removal_condition=is_rpc_wifi_stations_disabled, entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), @@ -959,6 +959,34 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): name="Analog input", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + removal_condition=lambda config, _status, key: (config[key]["enable"] is False), + ), + "analoginput_xpercent": RpcSensorDescription( + key="input", + sub_key="xpercent", + name="Analog value", + removal_condition=lambda config, status, key: ( + config[key]["enable"] is False or status[key].get("xpercent") is None + ), + ), + "pulse_counter": RpcSensorDescription( + key="input", + sub_key="counts", + name="Pulse counter", + native_unit_of_measurement="pulse", + state_class=SensorStateClass.TOTAL, + value=lambda status, _: status["total"], + removal_condition=lambda config, _status, key: (config[key]["enable"] is False), + ), + "counter_value": RpcSensorDescription( + key="input", + sub_key="counts", + name="Counter value", + value=lambda status, _: status["xtotal"], + removal_condition=lambda config, status, key: ( + config[key]["enable"] is False + or status[key]["counts"].get("xtotal") is None + ), ), } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index e5d91943a55700..a45fd9295f2946 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,7 +5,13 @@ from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS +from aioshelly.const import ( + MODEL_2, + MODEL_25, + MODEL_GAS, + MODEL_WALL_DISPLAY, + RPC_GENERATIONS, +) from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -20,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY +from .const import DOMAIN, GAS_VALVE_OPEN_STATES from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -35,6 +41,7 @@ get_rpc_key_ids, is_block_channel_type_light, is_rpc_channel_type_light, + is_rpc_thermostat_internal_actuator, ) @@ -128,7 +135,7 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if not coordinator.device.shelly.get("relay_in_thermostat", False): + if not is_rpc_thermostat_internal_actuator(coordinator.device.status): # Wall Display relay is not used as the thermostat actuator, # we need to remove a climate entity unique_id = f"{coordinator.mac}-thermostat:{id_}" @@ -153,6 +160,7 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): """ entity_description: BlockSwitchDescription + _attr_translation_key = "valve_switch" def __init__( self, @@ -173,11 +181,6 @@ def is_on(self) -> bool: return self.attribute_value in GAS_VALVE_OPEN_STATES - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:valve-open" if self.is_on else "mdi:valve-closed" - async def async_turn_on(self, **kwargs: Any) -> None: """Open valve.""" async_create_issue( diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index f5196504fe605c..9389f4e1507904 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -367,6 +367,11 @@ def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: return cast(str, con_types[channel]).lower().startswith("light") +def is_rpc_thermostat_internal_actuator(status: dict[str, Any]) -> bool: + """Return true if the thermostat uses an internal relay.""" + return cast(bool, status["sys"].get("relay_in_thermostat", False)) + + def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: """Return list of input triggers for RPC device.""" triggers = [] @@ -447,3 +452,13 @@ def async_create_issue_unsupported_firmware( "ip_address": entry.data["host"], }, ) + + +def is_rpc_wifi_stations_disabled( + config: dict[str, Any], _status: dict[str, Any], key: str +) -> bool: + """Return true if rpc all WiFi stations are disabled.""" + if config[key]["sta"]["enable"] is True or config[key]["sta1"]["enable"] is True: + return False + + return True diff --git a/homeassistant/components/signal_messenger/manifest.json b/homeassistant/components/signal_messenger/manifest.json index 9cdca39592aef4..058b01535ea162 100644 --- a/homeassistant/components/signal_messenger/manifest.json +++ b/homeassistant/components/signal_messenger/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/signal_messenger", "iot_class": "cloud_push", "loggers": ["pysignalclirestapi"], - "requirements": ["pysignalclirestapi==0.3.18"] + "requirements": ["pysignalclirestapi==0.3.23"] } diff --git a/homeassistant/components/smart_blinds/__init__.py b/homeassistant/components/smart_blinds/__init__.py new file mode 100644 index 00000000000000..af9ef7d4d48134 --- /dev/null +++ b/homeassistant/components/smart_blinds/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Smartblinds.""" diff --git a/homeassistant/components/smart_blinds/manifest.json b/homeassistant/components/smart_blinds/manifest.json index d0ddb30c5eea05..b1734de459811a 100644 --- a/homeassistant/components/smart_blinds/manifest.json +++ b/homeassistant/components/smart_blinds/manifest.json @@ -1,6 +1,6 @@ { "domain": "smart_blinds", - "name": "Smart Blinds", + "name": "Smartblinds", "integration_type": "virtual", "supported_by": "motion_blinds" } diff --git a/homeassistant/components/smart_home/__init__.py b/homeassistant/components/smart_home/__init__.py new file mode 100644 index 00000000000000..01290b93fc8359 --- /dev/null +++ b/homeassistant/components/smart_home/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Smart Home.""" diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index d1a3d5ae95f620..47b74c53db66f1 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -1,5 +1,4 @@ """The Smart Meter Texas integration.""" -import asyncio import logging import ssl @@ -47,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SmartMeterTexasAuthError: _LOGGER.error("Username or password was not accepted") return False - except asyncio.TimeoutError as error: + except TimeoutError as error: raise ConfigEntryNotReady from error await smart_meter_texas_data.setup() diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py index 53428131e1778d..dc0e4e93effcd6 100644 --- a/homeassistant/components/smart_meter_texas/config_flow.py +++ b/homeassistant/components/smart_meter_texas/config_flow.py @@ -1,5 +1,4 @@ """Config flow for Smart Meter Texas integration.""" -import asyncio import logging from aiohttp import ClientError @@ -36,7 +35,7 @@ async def validate_input(hass: core.HomeAssistant, data): try: await client.authenticate() - except (asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError) as error: + except (TimeoutError, ClientError, SmartMeterTexasAPIError) as error: raise CannotConnect from error except SmartMeterTexasAuthError as error: raise InvalidAuth(error) from error diff --git a/homeassistant/components/smarttub/controller.py b/homeassistant/components/smarttub/controller.py index 72157e086e3f06..353e20939970df 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -56,7 +56,7 @@ async def async_setup_entry(self, entry): # credentials were changed or invalidated, we need new ones raise ConfigEntryAuthFailed from ex except ( - asyncio.TimeoutError, + TimeoutError, client_exceptions.ClientOSError, client_exceptions.ServerDisconnectedError, client_exceptions.ContentTypeError, diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 5b3f60f4b0834f..1dbfb5ecedd7db 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -40,9 +40,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: }, } - if not hass.config_entries.async_update_entry(entry, data=new_data): + if not hass.config_entries.async_update_entry(entry, data=new_data, version=2): return False - entry.version = 2 - return True diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 05683f19b11428..5814db8168e4ef 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -171,7 +171,7 @@ async def async_update(self) -> None: self._forecast_daily = await self._smhi_api.async_get_forecast() self._forecast_hourly = await self._smhi_api.async_get_forecast_hour() self._fail_count = 0 - except (asyncio.TimeoutError, SmhiForecastException): + except (TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") self._fail_count += 1 if self._fail_count < 3: diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py index 6a787dd5e88335..bac51150ebaeda 100644 --- a/homeassistant/components/snapcast/server.py +++ b/homeassistant/components/snapcast/server.py @@ -134,6 +134,7 @@ def on_add_client(self, client: Snapclient) -> None: ---------- client : Snapclient Snapcast client to be added to HA. + """ if not self.hass_async_add_entities: return diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 2756b97157c2e0..dd9a2f5270aeb7 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": ["pysnmp-lextudio==5.0.31"] + "requirements": ["pysnmp-lextudio==6.0.2"] } diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index d0fe393d55083c..a30cf93bcded39 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -273,13 +273,13 @@ async def async_update(self) -> None: ) else: for resrow in restable: - if resrow[-1] == self._payload_on: + if resrow[-1] == self._payload_on or resrow[-1] == Integer( + self._payload_on + ): self._state = True - elif resrow[-1] == Integer(self._payload_on): - self._state = True - elif resrow[-1] == self._payload_off: - self._state = False - elif resrow[-1] == Integer(self._payload_off): + elif resrow[-1] == self._payload_off or resrow[-1] == Integer( + self._payload_off + ): self._state = False else: self._state = None diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index d2188eeec73041..7174fbc358c625 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -144,7 +144,7 @@ async def async_step_wait_for_pairing_mode( try: await self._pairing_task - except asyncio.TimeoutError: + except TimeoutError: return self.async_show_progress_done(next_step_id="pairing_timeout") finally: self._pairing_task = None diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 8ac6c4672fd1f2..7883c88f0b8fc6 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -1,5 +1,4 @@ """Component for the Somfy MyLink device supporting the Synergy API.""" -import asyncio import logging from somfy_mylink_synergy import SomfyMyLinkSynergy @@ -30,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: mylink_status = await somfy_mylink.status_info() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise ConfigEntryNotReady( "Unable to connect to the Somfy MyLink device, please check your settings" ) from ex diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index de38ac271ce585..e42191c1230ec8 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Somfy MyLink integration.""" from __future__ import annotations -import asyncio from copy import deepcopy import logging @@ -40,7 +39,7 @@ async def validate_input(hass: core.HomeAssistant, data): try: status_info = await somfy_mylink.status_info() - except asyncio.TimeoutError as ex: + except TimeoutError as ex: raise CannotConnect from ex if not status_info or "error" in status_info: diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index c592e8435c28b8..69d2ba76e229b5 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -104,8 +104,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: **entry.data, CONF_URL: f"{new_proto}://{new_host_port}{new_path}", } - hass.config_entries.async_update_entry(entry, data=data) - entry.version = 2 + hass.config_entries.async_update_entry(entry, data=data, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 7a8ced30eb7695..582e62a67eb906 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -72,7 +72,7 @@ async def async_setup_entry( 10 ): # set timeout to avoid blocking the setup process await device.get_supported_methods() - except (SongpalException, asyncio.TimeoutError) as ex: + except (SongpalException, TimeoutError) as ex: _LOGGER.warning("[%s(%s)] Unable to connect", name, endpoint) _LOGGER.debug("Unable to get methods from songpal: %s", ex) raise PlatformNotReady from ex diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c79856c58b60ab..0df6a7422fe0e9 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -393,7 +393,7 @@ async def async_poll_manual_hosts( OSError, SoCoException, Timeout, - asyncio.TimeoutError, + TimeoutError, ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( @@ -447,7 +447,7 @@ async def async_poll_manual_hosts( OSError, SoCoException, Timeout, - asyncio.TimeoutError, + TimeoutError, ) as ex: _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex) elif not known_speaker.available: diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 58a0ec3b7ee3a1..929b6639e9fcdd 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", + "import_executor": true, "iot_class": "local_push", "loggers": ["soco"], "requirements": ["soco==0.30.2", "sonos-websocket==0.1.3"], diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index fea5b5de7deb03..3c9e4692fdc198 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -613,7 +613,9 @@ def async_check_activity(self, now: datetime.datetime) -> None: 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" + self._async_check_activity(), + f"sonos {self.uid} {self.zone_name} ping", + eager_start=True, ) async def _async_check_activity(self) -> None: @@ -1126,7 +1128,7 @@ def _test_groups(groups: list[list[SonosSpeaker]]) -> bool: async with asyncio.timeout(5): while not _test_groups(groups): await hass.data[DATA_SONOS].topology_condition.wait() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py index 1a2a868608e00d..32b63c42370910 100644 --- a/homeassistant/components/splunk/__init__.py +++ b/homeassistant/components/splunk/__init__.py @@ -1,5 +1,4 @@ """Support to send data to a Splunk instance.""" -import asyncio from http import HTTPStatus import json import logging @@ -120,7 +119,7 @@ async def splunk_event_listener(event): _LOGGER.warning(err) except ClientConnectionError as err: _LOGGER.warning(err) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning("Connection to %s:%s timed out", host, port) except ClientResponseError as err: _LOGGER.error(err.message) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 84f2bc102e3b66..94475794fdf9be 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/spotify", + "import_executor": true, "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotipy"], diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 1188a9ec05e055..b440b795e0e6ba 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.25", "sqlparse==0.4.4"] + "requirements": ["SQLAlchemy==2.0.27", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index c4e6db4c623a55..063627f9f430fb 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -184,10 +184,14 @@ async def async_setup_sensor( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the SQL sensor.""" - instance = get_instance(hass) + try: + instance = get_instance(hass) + except KeyError: # No recorder loaded + uses_recorder_db = False + else: + uses_recorder_db = db_url == instance.db_url sessmaker: scoped_session | None sql_data = _async_get_or_init_domain_data(hass) - uses_recorder_db = db_url == instance.db_url use_database_executor = False if uses_recorder_db and instance.dialect_name == SupportedDialect.SQLITE: use_database_executor = True diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index b155c7eddc00be..d2786bf213bcd7 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -140,7 +140,7 @@ async def async_step_user(self, user_input=None): async with asyncio.timeout(TIMEOUT): await self._discover() return await self.async_step_edit() - except asyncio.TimeoutError: + except TimeoutError: errors["base"] = "no_server_found" # display the form diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index a2df2c313cdc96..69647925c4712a 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -51,6 +51,7 @@ from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_ssdp, bind_hass +from homeassistant.util.async_ import create_eager_task DOMAIN = "ssdp" SSDP_SCANNER = "scanner" @@ -335,7 +336,10 @@ async def async_stop(self, *_: Any) -> None: async def _async_stop_ssdp_listeners(self) -> None: """Stop the SSDP listeners.""" await asyncio.gather( - *(listener.async_stop() for listener in self._ssdp_listeners), + *( + create_eager_task(listener.async_stop()) + for listener in self._ssdp_listeners + ), return_exceptions=True, ) @@ -399,7 +403,10 @@ async def _async_start_ssdp_listeners(self) -> None: ) ) results = await asyncio.gather( - *(listener.async_start() for listener in self._ssdp_listeners), + *( + create_eager_task(listener.async_start()) + for listener in self._ssdp_listeners + ), return_exceptions=True, ) failed_listeners = [] @@ -446,7 +453,8 @@ def _ssdp_listener_callback( self.hass.async_create_task( self._ssdp_listener_process_callback_with_lookup( ssdp_device, dst, source - ) + ), + eager_start=True, ) return @@ -501,7 +509,8 @@ def _ssdp_listener_process_callback( if callbacks: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] self.hass.async_create_task( - _async_process_callbacks(callbacks, discovery_info, ssdp_change) + _async_process_callbacks(callbacks, discovery_info, ssdp_change), + eager_start=True, ) # Config flows should only be created for alive/update messages from alive devices diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 2737565822d6f1..6d8010d6b8e2d4 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -1,10 +1,10 @@ { "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", - "after_dependencies": ["zeroconf"], "codeowners": [], "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/ssdp", + "import_executor": true, "integration_type": "system", "iot_class": "local_push", "loggers": ["async_upnp_client"], diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index ca8118d6b43f0b..1ddcbc9373bc20 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -44,7 +44,7 @@ def battery_level(self): @property def location_accuracy(self): """Return the gps accuracy of the device.""" - return self._device.position["r"] if "r" in self._device.position else 0 + return self._device.position.get("r", 0) @property def latitude(self): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 90cb80a964289e..817780a92828b2 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -186,6 +186,7 @@ CONF_STATE_CHARACTERISTIC = "state_characteristic" CONF_SAMPLES_MAX_BUFFER_SIZE = "sampling_size" CONF_MAX_AGE = "max_age" +CONF_KEEP_LAST_SAMPLE = "keep_last_sample" CONF_PRECISION = "precision" CONF_PERCENTILE = "percentile" @@ -221,6 +222,16 @@ def valid_boundary_configuration(config: dict[str, Any]) -> dict[str, Any]: return config +def valid_keep_last_sample(config: dict[str, Any]) -> dict[str, Any]: + """Validate that if keep_last_sample is set, max_age must also be set.""" + + if config.get(CONF_KEEP_LAST_SAMPLE) is True and config.get(CONF_MAX_AGE) is None: + raise vol.RequiredFieldInvalid( + "The sensor configuration must provide 'max_age' if 'keep_last_sample' is True" + ) + return config + + _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend( { vol.Required(CONF_ENTITY_ID): cv.entity_id, @@ -231,6 +242,7 @@ def valid_boundary_configuration(config: dict[str, Any]) -> dict[str, Any]: vol.Coerce(int), vol.Range(min=1) ), vol.Optional(CONF_MAX_AGE): cv.time_period, + vol.Optional(CONF_KEEP_LAST_SAMPLE, default=False): cv.boolean, vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int), vol.Optional(CONF_PERCENTILE, default=50): vol.All( vol.Coerce(int), vol.Range(min=1, max=99) @@ -241,6 +253,7 @@ def valid_boundary_configuration(config: dict[str, Any]) -> dict[str, Any]: _PLATFORM_SCHEMA_BASE, valid_state_characteristic_configuration, valid_boundary_configuration, + valid_keep_last_sample, ) @@ -263,6 +276,7 @@ async def async_setup_platform( state_characteristic=config[CONF_STATE_CHARACTERISTIC], samples_max_buffer_size=config.get(CONF_SAMPLES_MAX_BUFFER_SIZE), samples_max_age=config.get(CONF_MAX_AGE), + samples_keep_last=config[CONF_KEEP_LAST_SAMPLE], precision=config[CONF_PRECISION], percentile=config[CONF_PERCENTILE], ) @@ -282,6 +296,7 @@ def __init__( state_characteristic: str, samples_max_buffer_size: int | None, samples_max_age: timedelta | None, + samples_keep_last: bool, precision: int, percentile: int, ) -> None: @@ -297,6 +312,7 @@ def __init__( self._state_characteristic: str = state_characteristic self._samples_max_buffer_size: int | None = samples_max_buffer_size self._samples_max_age: timedelta | None = samples_max_age + self.samples_keep_last: bool = samples_keep_last self._precision: int = precision self._percentile: int = percentile self._value: StateType | datetime = None @@ -381,12 +397,14 @@ def _derive_unit_of_measurement(self, new_state: State) -> str | None: unit = None elif self._state_characteristic in STATS_NUMERIC_RETAIN_UNIT: unit = base_unit - elif self._state_characteristic in STATS_NOT_A_NUMBER: - unit = None - elif self._state_characteristic in ( - STAT_COUNT, - STAT_COUNT_BINARY_ON, - STAT_COUNT_BINARY_OFF, + elif ( + self._state_characteristic in STATS_NOT_A_NUMBER + or self._state_characteristic + in ( + STAT_COUNT, + STAT_COUNT_BINARY_ON, + STAT_COUNT_BINARY_OFF, + ) ): unit = None elif self._state_characteristic == STAT_VARIANCE: @@ -454,13 +472,27 @@ def _purge_old_states(self, max_age: timedelta) -> None: now = dt_util.utcnow() _LOGGER.debug( - "%s: purging records older then %s(%s)", + "%s: purging records older then %s(%s)(keep_last_sample: %s)", self.entity_id, dt_util.as_local(now - max_age), self._samples_max_age, + self.samples_keep_last, ) while self.ages and (now - self.ages[0]) > max_age: + if self.samples_keep_last and len(self.ages) == 1: + # Under normal circumstance this will not be executed, as a purge will not + # be scheduled for the last value if samples_keep_last is enabled. + # If this happens to be called outside normal scheduling logic or a + # source sensor update, this ensures the last value is preserved. + _LOGGER.debug( + "%s: preserving expired record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(self.ages[0]), + (now - self.ages[0]), + ) + break + _LOGGER.debug( "%s: purging record with datetime %s(%s)", self.entity_id, @@ -473,6 +505,17 @@ def _purge_old_states(self, max_age: timedelta) -> None: def _next_to_purge_timestamp(self) -> datetime | None: """Find the timestamp when the next purge would occur.""" if self.ages and self._samples_max_age: + if self.samples_keep_last and len(self.ages) == 1: + # Preserve the most recent entry if it is the only value. + # Do not schedule another purge. When a new source + # value is inserted it will restart purge cycle. + _LOGGER.debug( + "%s: skipping purge cycle for last record with datetime %s(%s)", + self.entity_id, + dt_util.as_local(self.ages[0]), + (dt_util.utcnow() - self.ages[0]), + ) + return None # Take the oldest entry from the ages list and add the configured max_age. # If executed after purging old states, the result is the next timestamp # in the future when the oldest state will expire. @@ -489,6 +532,8 @@ async def async_update(self) -> None: self._update_value() # If max_age is set, ensure to update again after the defined interval. + # By basing updates off the timestamps of sampled data we avoid updating + # when none of the observed entities change. if timestamp := self._next_to_purge_timestamp(): _LOGGER.debug("%s: scheduling update at %s", self.entity_id, timestamp) if self._update_listener: diff --git a/homeassistant/components/steamist/__init__.py b/homeassistant/components/steamist/__init__.py index ee46d644847d86..e84615b9352c31 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import DISCOVER_SCAN_TIMEOUT, DISCOVERY, DOMAIN, STARTUP_SCAN_TIMEOUT +from .const import DISCOVER_SCAN_TIMEOUT, DISCOVERY, DOMAIN from .coordinator import SteamistDataUpdateCoordinator from .discovery import ( async_discover_device, @@ -32,14 +32,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the steamist component.""" domain_data = hass.data.setdefault(DOMAIN, {}) - domain_data[DISCOVERY] = await async_discover_devices(hass, STARTUP_SCAN_TIMEOUT) + domain_data[DISCOVERY] = [] async def _async_discovery(*_: Any) -> None: async_trigger_discovery( hass, await async_discover_devices(hass, DISCOVER_SCAN_TIMEOUT) ) - async_trigger_discovery(hass, domain_data[DISCOVERY]) + hass.async_create_background_task( + _async_discovery(), "steamist-discovery", eager_start=True + ) async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) return True diff --git a/homeassistant/components/steamist/config_flow.py b/homeassistant/components/steamist/config_flow.py index f182189a9c7bfe..3d5fe000f3539b 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -170,6 +170,9 @@ async def async_step_user( errors["base"] = "unknown" else: if discovery := await async_discover_device(self.hass, host): + await self.async_set_unique_id( + dr.format_mac(discovery.mac), raise_on_progress=False + ) return self._async_create_entry_from_device(discovery) self._async_abort_entries_match({CONF_HOST: host}) return self.async_create_entry(title=host, data=user_input) diff --git a/homeassistant/components/steamist/const.py b/homeassistant/components/steamist/const.py index cacd79b77ac9ce..ae75193a3cc8d5 100644 --- a/homeassistant/components/steamist/const.py +++ b/homeassistant/components/steamist/const.py @@ -1,12 +1,11 @@ """Constants for the Steamist integration.""" -import asyncio import aiohttp DOMAIN = "steamist" -CONNECTION_EXCEPTIONS = (asyncio.TimeoutError, aiohttp.ClientError) +CONNECTION_EXCEPTIONS = (TimeoutError, aiohttp.ClientError) STARTUP_SCAN_TIMEOUT = 5 DISCOVER_SCAN_TIMEOUT = 10 diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 5768f886adbcfa..1d2957b35a3ebf 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -333,7 +333,7 @@ async def part_recv(self, timeout: float | None = None) -> bool: try: async with asyncio.timeout(timeout): await self._part_event.wait() - except asyncio.TimeoutError: + except TimeoutError: return False return True diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 3d27637c9890ce..0badd8ebc42cf1 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -421,8 +421,7 @@ def peek(self) -> Generator[av.Packet, None, None]: # Items consumed are added to a buffer for future calls to __next__ # or peek. First iterate over the buffer from previous calls to peek. self._next = self._pop_buffer - for packet in self._buffer: - yield packet + yield from self._buffer for packet in self._iterator: self._buffer.append(packet) yield packet diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index 66c3981705c369..02d78dfee41424 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -28,8 +28,8 @@ def get_client() -> SuezClient: if not client.check_credentials(): raise ConfigEntryError return client - except PySuezError: - raise ConfigEntryNotReady + except PySuezError as ex: + raise ConfigEntryNotReady from ex hass.data.setdefault(DOMAIN, {})[ entry.entry_id diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index ba288c90e34058..d01b8035a0c82c 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -40,8 +40,8 @@ def validate_input(data: dict[str, Any]) -> None: ) if not client.check_credentials(): raise InvalidAuth - except PySuezError: - raise CannotConnect + except PySuezError as ex: + raise CannotConnect from ex class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index d87b711e376241..d0713ddf1d162f 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -41,13 +41,10 @@ async def async_setup_entry( f"Timeout while connecting for entry '{start} {destination}'" ) from e except OpendataTransportError as e: - _LOGGER.error( - "Setup failed for entry '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names are valid", - start, - destination, - ) raise ConfigEntryError( - f"Setup failed for entry '{start} {destination}' with invalid data" + f"Setup failed for entry '{start} {destination}' with invalid data, check " + "at http://transport.opendata.ch/examples/stationboard.html if your " + "station names are valid" ) from e coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) @@ -107,8 +104,9 @@ async def async_migrate_entry( ) # Set a valid unique id for config entries - config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, unique_id=new_unique_id) + hass.config_entries.async_update_entry( + config_entry, unique_id=new_unique_id, minor_version=2 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/switch/icons.json b/homeassistant/components/switch/icons.json index 00520914b9f78c..fbc1af5a126ba4 100644 --- a/homeassistant/components/switch/icons.json +++ b/homeassistant/components/switch/icons.json @@ -1,7 +1,10 @@ { "entity_component": { "_": { - "default": "mdi:toggle-switch-variant" + "default": "mdi:toggle-switch-variant", + "state": { + "off": "mdi:toggle-switch-variant-off" + } }, "switch": { "default": "mdi:toggle-switch-variant", diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 3fe2ff7bc7d128..d94c7c9f098153 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -114,8 +114,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> options = {**config_entry.options} if config_entry.minor_version < 2: options.setdefault(CONF_INVERT, False) - config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) _LOGGER.debug( "Migration to version %s.%s successful", diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index dee1fe5cd8f5f8..d5e182a31dc371 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -156,7 +156,7 @@ def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: hass, config_entry.entry_id, update_unique_id ) - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index 1965867887ccdd..29679605e8b63a 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -115,7 +115,7 @@ def _async_handle_bluetooth_event( async def async_wait_ready(self) -> bool: """Wait for the device to be ready.""" - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(DEVICE_STARTUP_TIMEOUT): await self._ready_event.wait() return True diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 2085398232f7be..64571f15af0485 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -1,7 +1,6 @@ """Switcher integration Button platform.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -147,7 +146,7 @@ async def async_press(self) -> None: self.coordinator.data.device_key, ) as swapi: response = await self.entity_description.press_fn(swapi, self._remote) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 01c4814f9850bc..180b71b1fe6f96 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -1,7 +1,6 @@ """Switcher integration Climate platform.""" from __future__ import annotations -import asyncio from typing import Any, cast from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api @@ -172,7 +171,7 @@ async def _async_control_breeze_device(self, **kwargs: Any) -> None: self.coordinator.data.device_key, ) as swapi: response = await swapi.control_breeze_device(self._remote, **kwargs) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 1e34ddd23256c9..4d81480e136160 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -1,7 +1,6 @@ """Switcher integration Cover platform.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -103,7 +102,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None: self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 88867393834c3a..c24157f70fce48 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -1,7 +1,6 @@ """Switcher integration Switch platform.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging from typing import Any @@ -118,7 +117,7 @@ async def _async_call_api(self, api: str, *args: Any) -> None: self.coordinator.data.device_key, ) as swapi: response = await getattr(swapi, api)(*args) - except (asyncio.TimeoutError, OSError, RuntimeError) as err: + except (TimeoutError, OSError, RuntimeError) as err: error = repr(err) if error or not response or not response.successful: diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 8060bce5c9b293..2820c99f8898f5 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/synology_dsm", + "import_executor": true, "iot_class": "local_polling", "loggers": ["synology_dsm"], "requirements": ["py-synologydsm-api==2.1.4"], diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 9eec64ec5f61b9..d2f5c795b7f429 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -97,7 +97,7 @@ async def async_setup_entry( raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception @@ -117,7 +117,7 @@ async def async_setup_entry( raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception @@ -134,7 +134,7 @@ async def async_setup_entry( entry.data[CONF_HOST], ) await asyncio.sleep(1) - except asyncio.TimeoutError as exception: + except TimeoutError as exception: raise ConfigEntryNotReady( f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 1d36c673eb634c..7c2607e3506509 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -23,10 +23,6 @@ class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing System Bridge binary sensor entities.""" - # SystemBridgeBinarySensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - value: Callable = round diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a001f22c9e8470..0b6a8b4622bf2c 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -75,7 +75,7 @@ async def _validate_input( "Connection error when connecting to %s: %s", data[CONF_HOST], exception ) raise CannotConnect from exception - except asyncio.TimeoutError as exception: + except TimeoutError as exception: _LOGGER.warning("Timed out connecting to %s: %s", data[CONF_HOST], exception) raise CannotConnect from exception except ValueError as exception: diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 5a606721b00b00..532092ab133b75 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -215,7 +215,7 @@ async def _setup_websocket(self) -> None: ) self.last_update_success = False self.async_update_listeners() - except asyncio.TimeoutError as exception: + except TimeoutError as exception: self.logger.warning( "Timed out waiting for %s. Will retry: %s", self.title, diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index cd3cad8024ec1e..7c4d0f9ac46f55 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -70,7 +70,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def _register_system_health_platform( +@callback +def _register_system_health_platform( hass: HomeAssistant, integration_domain: str, platform: SystemHealthProtocol ) -> None: """Register a system health platform.""" @@ -85,7 +86,7 @@ async def get_integration_info( assert registration.info_callback async with asyncio.timeout(INFO_CALLBACK_TIMEOUT): data = await registration.info_callback(hass) - except asyncio.TimeoutError: + except TimeoutError: data = {"error": {"type": "failed", "error": "timeout"}} except Exception: # pylint: disable=broad-except _LOGGER.exception("Error fetching info") @@ -236,7 +237,7 @@ async def async_check_can_reach_url( return "ok" except aiohttp.ClientError: data = {"type": "failed", "error": "unreachable"} - except asyncio.TimeoutError: + except TimeoutError: data = {"type": "failed", "error": "timeout"} if more_info is not None: data["more_info"] = more_info diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 69dbb1f7952c9b..9fc5c91f085f2e 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -1,15 +1,26 @@ """The System Monitor integration.""" +import logging + +import psutil_home_assistant as ha_psutil + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.SENSOR] +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up System Monitor from a config entry.""" - + psutil_wrapper = await hass.async_add_executor_job(ha_psutil.PsutilWrapper) + hass.data[DOMAIN] = psutil_wrapper await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -23,3 +34,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + if entry.version == 1: + new_options = {**entry.options} + if entry.minor_version == 1: + # Migration copies process sensors to binary sensors + # Repair will remove sensors when user submit the fix + if processes := entry.options.get(SENSOR_DOMAIN): + new_options[BINARY_SENSOR_DOMAIN] = processes + hass.config_entries.async_update_entry( + entry, options=new_options, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py new file mode 100644 index 00000000000000..89c2e9d854ecc8 --- /dev/null +++ b/homeassistant/components/systemmonitor/binary_sensor.py @@ -0,0 +1,150 @@ +"""Binary sensors for System Monitor.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import lru_cache +import logging +import sys +from typing import Generic, Literal + +from psutil import NoSuchProcess, Process +import psutil_home_assistant as ha_psutil + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import CONF_PROCESS, DOMAIN +from .coordinator import MonitorCoordinator, SystemMonitorProcessCoordinator, dataT + +_LOGGER = logging.getLogger(__name__) + +CONF_ARG = "arg" + + +SENSOR_TYPE_NAME = 0 +SENSOR_TYPE_UOM = 1 +SENSOR_TYPE_ICON = 2 +SENSOR_TYPE_DEVICE_CLASS = 3 +SENSOR_TYPE_MANDATORY_ARG = 4 + +SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" + + +@lru_cache +def get_cpu_icon() -> Literal["mdi:cpu-64-bit", "mdi:cpu-32-bit"]: + """Return cpu icon.""" + if sys.maxsize > 2**32: + return "mdi:cpu-64-bit" + return "mdi:cpu-32-bit" + + +def get_process(entity: SystemMonitorSensor[list[Process]]) -> bool: + """Return process.""" + state = False + for proc in entity.coordinator.data: + try: + _LOGGER.debug("process %s for argument %s", proc.name(), entity.argument) + if entity.argument == proc.name(): + state = True + break + except NoSuchProcess as err: + _LOGGER.warning( + "Failed to load process with ID: %s, old name: %s", + err.pid, + err.name, + ) + return state + + +@dataclass(frozen=True, kw_only=True) +class SysMonitorBinarySensorEntityDescription( + BinarySensorEntityDescription, Generic[dataT] +): + """Describes System Monitor binary sensor entities.""" + + value_fn: Callable[[SystemMonitorSensor[dataT]], bool] + + +SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription[list[Process]], ...] = ( + SysMonitorBinarySensorEntityDescription[list[Process]]( + key="binary_process", + translation_key="process", + icon=get_cpu_icon(), + value_fn=get_process, + device_class=BinarySensorDeviceClass.RUNNING, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up System Montor binary sensors based on a config entry.""" + psutil_wrapper: ha_psutil.PsutilWrapper = hass.data[DOMAIN] + + entities: list[SystemMonitorSensor] = [] + process_coordinator = SystemMonitorProcessCoordinator( + hass, psutil_wrapper, "Process coordinator" + ) + await process_coordinator.async_request_refresh() + + for sensor_description in SENSOR_TYPES: + _entry = entry.options.get(BINARY_SENSOR_DOMAIN, {}) + for argument in _entry.get(CONF_PROCESS, []): + entities.append( + SystemMonitorSensor( + process_coordinator, + sensor_description, + entry.entry_id, + argument, + ) + ) + async_add_entities(entities) + + +class SystemMonitorSensor( + CoordinatorEntity[MonitorCoordinator[dataT]], BinarySensorEntity +): + """Implementation of a system monitor binary sensor.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + entity_description: SysMonitorBinarySensorEntityDescription[dataT] + + def __init__( + self, + coordinator: MonitorCoordinator[dataT], + sensor_description: SysMonitorBinarySensorEntityDescription[dataT], + entry_id: str, + argument: str, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = sensor_description + self._attr_translation_placeholders = {"process": argument} + self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="System Monitor", + name="System Monitor", + ) + self.argument = argument + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py index 6d9787a39f5d4d..b9b95a4a094336 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -6,8 +6,8 @@ import voluptuous as vol +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import entity_registry as er @@ -34,7 +34,7 @@ async def validate_sensor_setup( """Validate sensor input.""" # 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: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.setdefault(BINARY_SENSOR_DOMAIN, {}) processes = sensors.setdefault(CONF_PROCESS, []) previous_processes = processes.copy() processes.clear() @@ -44,7 +44,7 @@ async def validate_sensor_setup( for process in previous_processes: if process not in processes and ( entity_id := entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, slugify(f"process_{process}") + BINARY_SENSOR_DOMAIN, DOMAIN, slugify(f"binary_process_{process}") ) ): entity_registry.async_remove(entity_id) @@ -58,7 +58,7 @@ async def validate_import_sensor_setup( """Validate sensor input.""" # 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: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.setdefault(BINARY_SENSOR_DOMAIN, {}) import_processes: list[str] = user_input["processes"] processes = sensors.setdefault(CONF_PROCESS, []) processes.extend(import_processes) @@ -86,7 +86,7 @@ async def validate_import_sensor_setup( async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: """Return process sensor setup schema.""" hass = handler.parent_handler.hass - processes = list(await hass.async_add_executor_job(get_all_running_processes)) + processes = list(await hass.async_add_executor_job(get_all_running_processes, hass)) return vol.Schema( { vol.Required(CONF_PROCESS): SelectSelector( @@ -104,7 +104,7 @@ async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schem async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any]: """Return suggested values for sensor setup.""" - sensors: dict[str, list] = handler.options.get(SENSOR_DOMAIN, {}) + sensors: dict[str, list] = handler.options.get(BINARY_SENSOR_DOMAIN, {}) processes: list[str] = sensors.get(CONF_PROCESS, []) return {CONF_PROCESS: processes} @@ -130,6 +130,8 @@ class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py index 798cb82f8effad..1f254ca92d62ac 100644 --- a/homeassistant/components/systemmonitor/const.py +++ b/homeassistant/components/systemmonitor/const.py @@ -1,6 +1,7 @@ """Constants for System Monitor.""" DOMAIN = "systemmonitor" +DOMAIN_COORDINATORS = "systemmonitor_coordinators" CONF_INDEX = "index" CONF_PROCESS = "process" diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index bf625eacf9ade0..6f93b9ddce8b1c 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -8,12 +8,16 @@ import os from typing import NamedTuple, TypeVar -import psutil +from psutil import Process from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap +import psutil_home_assistant as ha_psutil from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + TimestampDataUpdateCoordinator, + UpdateFailed, +) from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -40,7 +44,7 @@ class VirtualMemory(NamedTuple): | dict[str, list[snicaddr]] | dict[str, snetio] | float - | list[psutil.Process] + | list[Process] | sswap | VirtualMemory | tuple[float, float, float] @@ -49,10 +53,12 @@ class VirtualMemory(NamedTuple): ) -class MonitorCoordinator(DataUpdateCoordinator[dataT]): +class MonitorCoordinator(TimestampDataUpdateCoordinator[dataT]): """A System monitor Base Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, name: str) -> None: + def __init__( + self, hass: HomeAssistant, psutil_wrapper: ha_psutil.PsutilWrapper, name: str + ) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -61,6 +67,7 @@ def __init__(self, hass: HomeAssistant, name: str) -> None: update_interval=DEFAULT_SCAN_INTERVAL, always_update=False, ) + self._psutil = psutil_wrapper.psutil async def _async_update_data(self) -> dataT: """Fetch data.""" @@ -74,15 +81,23 @@ def update_data(self) -> dataT: class SystemMonitorDiskCoordinator(MonitorCoordinator[sdiskusage]): """A System monitor Disk Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, name: str, argument: str) -> None: + def __init__( + self, + hass: HomeAssistant, + psutil_wrapper: ha_psutil.PsutilWrapper, + name: str, + argument: str, + ) -> None: """Initialize the disk coordinator.""" - super().__init__(hass, name) + super().__init__(hass, psutil_wrapper, name) self._argument = argument def update_data(self) -> sdiskusage: """Fetch data.""" try: - return psutil.disk_usage(self._argument) + usage: sdiskusage = self._psutil.disk_usage(self._argument) + _LOGGER.debug("sdiskusage: %s", usage) + return usage except PermissionError as err: raise UpdateFailed(f"No permission to access {self._argument}") from err except OSError as err: @@ -94,7 +109,9 @@ class SystemMonitorSwapCoordinator(MonitorCoordinator[sswap]): def update_data(self) -> sswap: """Fetch data.""" - return psutil.swap_memory() + swap: sswap = self._psutil.swap_memory() + _LOGGER.debug("sswap: %s", swap) + return swap class SystemMonitorMemoryCoordinator(MonitorCoordinator[VirtualMemory]): @@ -102,7 +119,8 @@ class SystemMonitorMemoryCoordinator(MonitorCoordinator[VirtualMemory]): def update_data(self) -> VirtualMemory: """Fetch data.""" - memory = psutil.virtual_memory() + memory = self._psutil.virtual_memory() + _LOGGER.debug("memory: %s", memory) return VirtualMemory( memory.total, memory.available, memory.percent, memory.used, memory.free ) @@ -113,7 +131,9 @@ class SystemMonitorNetIOCoordinator(MonitorCoordinator[dict[str, snetio]]): def update_data(self) -> dict[str, snetio]: """Fetch data.""" - return psutil.net_io_counters(pernic=True) + io_counters: dict[str, snetio] = self._psutil.net_io_counters(pernic=True) + _LOGGER.debug("io_counters: %s", io_counters) + return io_counters class SystemMonitorNetAddrCoordinator(MonitorCoordinator[dict[str, list[snicaddr]]]): @@ -121,13 +141,20 @@ class SystemMonitorNetAddrCoordinator(MonitorCoordinator[dict[str, list[snicaddr def update_data(self) -> dict[str, list[snicaddr]]: """Fetch data.""" - return psutil.net_if_addrs() + addresses: dict[str, list[snicaddr]] = self._psutil.net_if_addrs() + _LOGGER.debug("ip_addresses: %s", addresses) + return addresses -class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float]]): +class SystemMonitorLoadCoordinator( + MonitorCoordinator[tuple[float, float, float] | None] +): """A System monitor Load Data Update Coordinator.""" - def update_data(self) -> tuple[float, float, float]: + def update_data(self) -> tuple[float, float, float] | None: + """Coordinator is not async.""" + + async def _async_update_data(self) -> tuple[float, float, float] | None: """Fetch data.""" return os.getloadavg() @@ -136,8 +163,18 @@ class SystemMonitorProcessorCoordinator(MonitorCoordinator[float | None]): """A System monitor Processor Data Update Coordinator.""" def update_data(self) -> float | None: - """Fetch data.""" - cpu_percent = psutil.cpu_percent(interval=None) + """Coordinator is not async.""" + + async def _async_update_data(self) -> float | None: + """Get cpu usage. + + Unlikely the rest of the coordinators, this one is async + since it does not block and we need to make sure it runs + in the same thread every time as psutil checks the thread + tid and compares it against the previous one. + """ + cpu_percent: float = self._psutil.cpu_percent(interval=None) + _LOGGER.debug("cpu_percent: %s", cpu_percent) if cpu_percent > 0.0: return cpu_percent return None @@ -148,15 +185,18 @@ class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]): def update_data(self) -> datetime: """Fetch data.""" - return dt_util.utc_from_timestamp(psutil.boot_time()) + boot_time = dt_util.utc_from_timestamp(self._psutil.boot_time()) + _LOGGER.debug("boot time: %s", boot_time) + return boot_time -class SystemMonitorProcessCoordinator(MonitorCoordinator[list[psutil.Process]]): +class SystemMonitorProcessCoordinator(MonitorCoordinator[list[Process]]): """A System monitor Process Data Update Coordinator.""" - def update_data(self) -> list[psutil.Process]: + def update_data(self) -> list[Process]: """Fetch data.""" - processes = psutil.process_iter() + processes = self._psutil.process_iter() + _LOGGER.debug("processes: %s", processes) return list(processes) @@ -166,6 +206,8 @@ class SystemMonitorCPUtempCoordinator(MonitorCoordinator[dict[str, list[shwtemp] def update_data(self) -> dict[str, list[shwtemp]]: """Fetch data.""" try: - return psutil.sensors_temperatures() + temps: dict[str, list[shwtemp]] = self._psutil.sensors_temperatures() + _LOGGER.debug("temps: %s", temps) + return temps except AttributeError as err: raise UpdateFailed("OS does not provide temperature sensors") from err diff --git a/homeassistant/components/systemmonitor/diagnostics.py b/homeassistant/components/systemmonitor/diagnostics.py new file mode 100644 index 00000000000000..d48097e936c8a0 --- /dev/null +++ b/homeassistant/components/systemmonitor/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for Sensibo.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN_COORDINATORS +from .coordinator import MonitorCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Sensibo config entry.""" + coordinators: dict[str, MonitorCoordinator] = hass.data[DOMAIN_COORDINATORS] + + diag_data = {} + for _type, coordinator in coordinators.items(): + diag_data[_type] = { + "last_update_success": coordinator.last_update_success, + "last_update": str(coordinator.last_update_success_time), + "data": str(coordinator.data), + } + + return { + "entry": entry.as_dict(), + "coordinators": diag_data, + } diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index b93bdefd838432..5e1ef6c02de9ca 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.8"] + "requirements": ["psutil-home-assistant==0.0.1", "psutil==5.9.8"] } diff --git a/homeassistant/components/systemmonitor/repairs.py b/homeassistant/components/systemmonitor/repairs.py new file mode 100644 index 00000000000000..10b5d18830d701 --- /dev/null +++ b/homeassistant/components/systemmonitor/repairs.py @@ -0,0 +1,72 @@ +"""Repairs platform for the System Monitor integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +class ProcessFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, entry: ConfigEntry, processes: list[str]) -> None: + """Create flow.""" + super().__init__() + self.entry = entry + self._processes = processes + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_migrate_process_sensor() + + async def async_step_migrate_process_sensor( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the options step of a fix flow.""" + if user_input is None: + return self.async_show_form( + step_id="migrate_process_sensor", + description_placeholders={"processes": ", ".join(self._processes)}, + ) + + # Migration has copied the sensors to binary sensors + # Pop the sensors to repair and remove entities + new_options: dict[str, Any] = self.entry.options.copy() + new_options.pop(SENSOR_DOMAIN) + + entity_reg = er.async_get(self.hass) + entries = er.async_entries_for_config_entry(entity_reg, self.entry.entry_id) + for entry in entries: + if entry.entity_id.startswith("sensor.") and entry.unique_id.startswith( + "process_" + ): + entity_reg.async_remove(entry.entity_id) + + self.hass.config_entries.async_update_entry(self.entry, options=new_options) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, Any] | None, +) -> RepairsFlow: + """Create flow.""" + entry = None + if data and (entry_id := data.get("entry_id")): + entry_id = cast(str, entry_id) + processes: list[str] = data["processes"] + entry = hass.config_entries.async_get_entry(entry_id) + assert entry + return ProcessFixFlow(entry, processes) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 813104e2de3037..1ebf2ba44e4aba 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,4 +1,5 @@ """Support for monitoring the local system.""" + from __future__ import annotations from collections.abc import Callable @@ -11,8 +12,9 @@ import time from typing import Any, Generic, Literal -import psutil +from psutil import NoSuchProcess, Process from psutil._common import sdiskusage, shwtemp, snetio, snicaddr, sswap +import psutil_home_assistant as ha_psutil import voluptuous as vol from homeassistant.components.sensor import ( @@ -35,15 +37,17 @@ UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, 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, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from .const import CONF_PROCESS, DOMAIN, NET_IO_TYPES +from .const import CONF_PROCESS, DOMAIN, DOMAIN_COORDINATORS, NET_IO_TYPES from .coordinator import ( MonitorCoordinator, SystemMonitorBootTimeCoordinator, @@ -87,10 +91,10 @@ def get_processor_temperature( entity: SystemMonitorSensor[dict[str, list[shwtemp]]], ) -> float | None: """Return processor temperature.""" - return read_cpu_temperature(entity.coordinator.data) + return read_cpu_temperature(entity.hass, entity.coordinator.data) -def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> str: +def get_process(entity: SystemMonitorSensor[list[Process]]) -> str: """Return process.""" state = STATE_OFF for proc in entity.coordinator.data: @@ -99,7 +103,7 @@ def get_process(entity: SystemMonitorSensor[list[psutil.Process]]) -> str: if entity.argument == proc.name(): state = STATE_ON break - except psutil.NoSuchProcess as err: + except NoSuchProcess as err: _LOGGER.warning( "Failed to load process with ID: %s, old name: %s", err.pid, @@ -330,7 +334,7 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription, Generic[dataT]) mandatory_arg=True, value_fn=get_throughput, ), - "process": SysMonitorSensorEntityDescription[list[psutil.Process]]( + "process": SysMonitorSensorEntityDescription[list[Process]]( key="process", translation_key="process", placeholder="process", @@ -485,12 +489,16 @@ async def async_setup_entry( # noqa: C901 entities: list[SystemMonitorSensor] = [] legacy_resources: set[str] = set(entry.options.get("resources", [])) loaded_resources: set[str] = set() + psutil_wrapper: ha_psutil.PsutilWrapper = hass.data[DOMAIN] def get_arguments() -> dict[str, Any]: """Return startup information.""" - disk_arguments = get_all_disk_mounts() - network_arguments = get_all_network_interfaces() - cpu_temperature = read_cpu_temperature() + disk_arguments = get_all_disk_mounts(hass) + network_arguments = get_all_network_interfaces(hass) + try: + cpu_temperature = read_cpu_temperature(hass) + except AttributeError: + cpu_temperature = 0.0 return { "disk_arguments": disk_arguments, "network_arguments": network_arguments, @@ -502,31 +510,39 @@ def get_arguments() -> dict[str, Any]: disk_coordinators: dict[str, SystemMonitorDiskCoordinator] = {} for argument in startup_arguments["disk_arguments"]: disk_coordinators[argument] = SystemMonitorDiskCoordinator( - hass, f"Disk {argument} coordinator", argument + hass, psutil_wrapper, f"Disk {argument} coordinator", argument ) - swap_coordinator = SystemMonitorSwapCoordinator(hass, "Swap coordinator") - memory_coordinator = SystemMonitorMemoryCoordinator(hass, "Memory coordinator") - net_io_coordinator = SystemMonitorNetIOCoordinator(hass, "Net IO coordnator") + swap_coordinator = SystemMonitorSwapCoordinator( + hass, psutil_wrapper, "Swap coordinator" + ) + memory_coordinator = SystemMonitorMemoryCoordinator( + hass, psutil_wrapper, "Memory coordinator" + ) + net_io_coordinator = SystemMonitorNetIOCoordinator( + hass, psutil_wrapper, "Net IO coordnator" + ) net_addr_coordinator = SystemMonitorNetAddrCoordinator( - hass, "Net address coordinator" + hass, psutil_wrapper, "Net address coordinator" ) system_load_coordinator = SystemMonitorLoadCoordinator( - hass, "System load coordinator" + hass, psutil_wrapper, "System load coordinator" ) processor_coordinator = SystemMonitorProcessorCoordinator( - hass, "Processor coordinator" + hass, psutil_wrapper, "Processor coordinator" ) boot_time_coordinator = SystemMonitorBootTimeCoordinator( - hass, "Boot time coordinator" + hass, psutil_wrapper, "Boot time coordinator" + ) + process_coordinator = SystemMonitorProcessCoordinator( + hass, psutil_wrapper, "Process coordinator" ) - process_coordinator = SystemMonitorProcessCoordinator(hass, "Process coordinator") cpu_temp_coordinator = SystemMonitorCPUtempCoordinator( - hass, "CPU temperature coordinator" + hass, psutil_wrapper, "CPU temperature coordinator" ) for argument in startup_arguments["disk_arguments"]: disk_coordinators[argument] = SystemMonitorDiskCoordinator( - hass, f"Disk {argument} coordinator", argument + hass, psutil_wrapper, f"Disk {argument} coordinator", argument ) _LOGGER.debug("Setup from options %s", entry.options) @@ -554,7 +570,7 @@ def get_arguments() -> dict[str, Any]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( net_addr_coordinator, @@ -569,7 +585,7 @@ def get_arguments() -> dict[str, Any]: if _type == "last_boot": argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( boot_time_coordinator, @@ -584,7 +600,7 @@ def get_arguments() -> dict[str, Any]: if _type.startswith("load_"): argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( system_load_coordinator, @@ -599,7 +615,7 @@ def get_arguments() -> dict[str, Any]: if _type.startswith("memory_"): argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( memory_coordinator, @@ -615,7 +631,7 @@ def get_arguments() -> dict[str, Any]: is_enabled = check_legacy_resource( f"{_type}_{argument}", legacy_resources ) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( net_io_coordinator, @@ -640,12 +656,26 @@ def get_arguments() -> dict[str, Any]: True, ) ) + async_create_issue( + hass, + DOMAIN, + "process_sensor", + breaks_in_ha_version="2024.9.0", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="process_sensor", + data={ + "entry_id": entry.entry_id, + "processes": _entry[CONF_PROCESS], + }, + ) continue if _type == "processor_use": argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( processor_coordinator, @@ -663,7 +693,7 @@ def get_arguments() -> dict[str, Any]: continue argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( cpu_temp_coordinator, @@ -678,7 +708,7 @@ def get_arguments() -> dict[str, Any]: if _type.startswith("swap_"): argument = "" is_enabled = check_legacy_resource(f"{_type}_{argument}", legacy_resources) - loaded_resources.add(f"{_type}_{argument}") + loaded_resources.add(slugify(f"{_type}_{argument}")) entities.append( SystemMonitorSensor( swap_coordinator, @@ -700,13 +730,14 @@ def get_arguments() -> dict[str, Any]: loaded_resources, ) if check_resource not in loaded_resources: + loaded_resources.add(check_resource) split_index = resource.rfind("_") _type = resource[:split_index] argument = resource[split_index + 1 :] _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) if not disk_coordinators.get(argument): disk_coordinators[argument] = SystemMonitorDiskCoordinator( - hass, f"Disk {argument} coordinator", argument + hass, psutil_wrapper, f"Disk {argument} coordinator", argument ) entities.append( SystemMonitorSensor( @@ -718,18 +749,43 @@ def get_arguments() -> dict[str, Any]: ) ) + hass.data[DOMAIN_COORDINATORS] = {} # No gathering to avoid swamping the executor - for coordinator in disk_coordinators.values(): + for argument, coordinator in disk_coordinators.items(): + hass.data[DOMAIN_COORDINATORS][f"disk_{argument}"] = coordinator + hass.data[DOMAIN_COORDINATORS]["boot_time"] = boot_time_coordinator + hass.data[DOMAIN_COORDINATORS]["cpu_temp"] = cpu_temp_coordinator + hass.data[DOMAIN_COORDINATORS]["memory"] = memory_coordinator + hass.data[DOMAIN_COORDINATORS]["net_addr"] = net_addr_coordinator + hass.data[DOMAIN_COORDINATORS]["net_io"] = net_io_coordinator + hass.data[DOMAIN_COORDINATORS]["process"] = process_coordinator + hass.data[DOMAIN_COORDINATORS]["processor"] = processor_coordinator + hass.data[DOMAIN_COORDINATORS]["swap"] = swap_coordinator + hass.data[DOMAIN_COORDINATORS]["system_load"] = system_load_coordinator + + for coordinator in hass.data[DOMAIN_COORDINATORS].values(): await coordinator.async_request_refresh() - await boot_time_coordinator.async_request_refresh() - await cpu_temp_coordinator.async_request_refresh() - await memory_coordinator.async_request_refresh() - await net_addr_coordinator.async_request_refresh() - await net_io_coordinator.async_request_refresh() - await process_coordinator.async_request_refresh() - await processor_coordinator.async_request_refresh() - await swap_coordinator.async_request_refresh() - await system_load_coordinator.async_request_refresh() + + @callback + def clean_obsolete_entities() -> None: + """Remove entities which are disabled and not supported from setup.""" + entity_registry = er.async_get(hass) + entities = entity_registry.entities.get_entries_for_config_entry_id( + entry.entry_id + ) + for entity in entities: + if ( + entity.unique_id not in loaded_resources + and entity.disabled is True + and ( + entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, entity.unique_id + ) + ) + ): + entity_registry.async_remove(entity_id) + + clean_obsolete_entities() async_add_entities(entities) diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index ff1fbc221ee229..aae2463c9da7ee 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -22,7 +22,25 @@ } } }, + "issues": { + "process_sensor": { + "title": "Process sensors are deprecated and will be removed", + "fix_flow": { + "step": { + "migrate_process_sensor": { + "title": "Process sensors have been setup as binary sensors", + "description": "Process sensors `{processes}` have been created as binary sensors and the sensors will be removed in 2024.9.0.\n\nPlease update all automations, scripts, dashboards or other things depending on these sensors to use the newly created binary sensors instead and press **Submit** to fix this issue." + } + } + } + } + }, "entity": { + "binary_sensor": { + "process": { + "name": "Process {process}" + } + }, "sensor": { "disk_free": { "name": "Disk free {mount_point}" diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 11d8fa9c062178..c67d4771ff46a4 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -3,20 +3,23 @@ import logging import os -import psutil from psutil._common import shwtemp +import psutil_home_assistant as ha_psutil -from .const import CPU_SENSOR_PREFIXES +from homeassistant.core import HomeAssistant + +from .const import CPU_SENSOR_PREFIXES, DOMAIN _LOGGER = logging.getLogger(__name__) SKIP_DISK_TYPES = {"proc", "tmpfs", "devtmpfs"} -def get_all_disk_mounts() -> set[str]: +def get_all_disk_mounts(hass: HomeAssistant) -> set[str]: """Return all disk mount points on system.""" + psutil_wrapper: ha_psutil = hass.data[DOMAIN] disks: set[str] = set() - for part in psutil.disk_partitions(all=True): + for part in psutil_wrapper.psutil.disk_partitions(all=True): if os.name == "nt": if "cdrom" in part.opts or part.fstype == "": # skip cd-rom drives with no disk in it; they may raise @@ -27,7 +30,13 @@ def get_all_disk_mounts() -> set[str]: # Ignore disks which are memory continue try: - usage = psutil.disk_usage(part.mountpoint) + if not os.path.isdir(part.mountpoint): + _LOGGER.debug( + "Mountpoint %s was excluded because it is not a directory", + part.mountpoint, + ) + continue + usage = psutil_wrapper.psutil.disk_usage(part.mountpoint) except PermissionError: _LOGGER.debug( "No permission for running user to access %s", part.mountpoint @@ -44,10 +53,11 @@ def get_all_disk_mounts() -> set[str]: return disks -def get_all_network_interfaces() -> set[str]: +def get_all_network_interfaces(hass: HomeAssistant) -> set[str]: """Return all network interfaces on system.""" + psutil_wrapper: ha_psutil = hass.data[DOMAIN] interfaces: set[str] = set() - for interface, _ in psutil.net_if_addrs().items(): + for interface, _ in psutil_wrapper.psutil.net_if_addrs().items(): if interface.startswith("veth"): # Don't load docker virtual network interfaces continue @@ -56,20 +66,24 @@ def get_all_network_interfaces() -> set[str]: return interfaces -def get_all_running_processes() -> set[str]: +def get_all_running_processes(hass: HomeAssistant) -> set[str]: """Return all running processes on system.""" + psutil_wrapper: ha_psutil = hass.data.get(DOMAIN, ha_psutil.PsutilWrapper()) processes: set[str] = set() - for proc in psutil.process_iter(["name"]): + for proc in psutil_wrapper.psutil.process_iter(["name"]): if proc.name() not in processes: processes.add(proc.name()) _LOGGER.debug("Running processes: %s", ", ".join(processes)) return processes -def read_cpu_temperature(temps: dict[str, list[shwtemp]] | None = None) -> float | None: +def read_cpu_temperature( + hass: HomeAssistant, temps: dict[str, list[shwtemp]] | None = None +) -> float | None: """Attempt to read CPU / processor temperature.""" - if not temps: - temps = psutil.sensors_temperatures() + if temps is None: + psutil_wrapper: ha_psutil = hass.data[DOMAIN] + temps = psutil_wrapper.psutil.sensors_temperatures() entry: shwtemp _LOGGER.debug("CPU Temperatures: %s", temps) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 871d6c2e6b19b8..c7225caaff990d 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,10 +10,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from .const import ( @@ -33,6 +34,7 @@ UPDATE_MOBILE_DEVICE_TRACK, UPDATE_TRACK, ) +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -52,6 +54,14 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Tado.""" + + setup_services(hass) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tado from a config entry.""" @@ -425,3 +435,10 @@ def set_temperature_offset(self, device_id, offset): self.tado.set_temp_offset(device_id, offset) except RequestException as exc: _LOGGER.error("Could not set temperature offset: %s", exc) + + def set_meter_reading(self, reading: int) -> dict[str, str]: + """Send meter reading to Tado.""" + try: + return self.tado.set_eiq_meter_readings(reading=reading) + except RequestException as exc: + raise HomeAssistantError("Could not set meter reading") from exc diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 0f7a1b2b307afd..c033ef62e037f6 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import TadoConnector from .const import ( DATA, DOMAIN, @@ -170,7 +171,10 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): entity_description: TadoBinarySensorEntityDescription def __init__( - self, tado, device_info, entity_description: TadoBinarySensorEntityDescription + self, + tado: TadoConnector, + device_info: dict[str, Any], + entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description @@ -183,7 +187,6 @@ def __init__( async def async_added_to_hass(self) -> None: """Register for sensor updates.""" - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -196,13 +199,13 @@ async def async_added_to_hass(self) -> None: self._async_update_device_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_device_data() self.async_write_ha_state() @callback - def _async_update_device_data(self): + def _async_update_device_data(self) -> None: """Handle update callbacks.""" try: self._device_info = self._tado.data["device"][self.device_id] @@ -223,9 +226,9 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): def __init__( self, - tado, - zone_name, - zone_id, + tado: TadoConnector, + zone_name: str, + zone_id: int, entity_description: TadoBinarySensorEntityDescription, ) -> None: """Initialize of the Tado Sensor.""" @@ -237,7 +240,6 @@ def __init__( async def async_added_to_hass(self) -> None: """Register for sensor updates.""" - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -250,13 +252,13 @@ async def async_added_to_hass(self) -> None: self._async_update_zone_data() @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Update and write state.""" self._async_update_zone_data() self.async_write_ha_state() @callback - def _async_update_zone_data(self): + def _async_update_zone_data(self) -> None: """Handle update callbacks.""" try: tado_zone_data = self._tado.data["zone"][self.zone_id] diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index dd0d6a22a08129..5d17655c10496b 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -1,9 +1,12 @@ """Support for Tado thermostats.""" + from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any +import PyTado import voluptuous as vol from homeassistant.components.climate import ( @@ -22,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TadoConnector from .const import ( CONST_EXCLUSIVE_OVERLAY_GROUP, CONST_FAN_AUTO, @@ -48,6 +52,8 @@ SIGNAL_TADO_UPDATE_RECEIVED, SUPPORT_PRESET_AUTO, SUPPORT_PRESET_MANUAL, + TADO_DEFAULT_MAX_TEMP, + TADO_DEFAULT_MIN_TEMP, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, @@ -111,7 +117,7 @@ async def async_setup_entry( async_add_entities(entities, True) -def _generate_entities(tado): +def _generate_entities(tado: TadoConnector) -> list[TadoClimate]: """Create all climate entities.""" entities = [] for zone in tado.zones: @@ -124,7 +130,9 @@ def _generate_entities(tado): return entities -def create_climate_entity(tado, name: str, zone_id: int, device_info: dict): +def create_climate_entity( + tado: TadoConnector, name: str, zone_id: int, device_info: dict +) -> TadoClimate | None: """Create a Tado climate entity.""" capabilities = tado.get_capabilities(zone_id) _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) @@ -203,16 +211,16 @@ def create_climate_entity(tado, name: str, zone_id: int, device_info: dict): name, zone_id, zone_type, + supported_hvac_modes, + support_flags, + device_info, heat_min_temp, heat_max_temp, heat_step, cool_min_temp, cool_max_temp, cool_step, - supported_hvac_modes, supported_fan_modes, - support_flags, - device_info, ) return entity @@ -228,21 +236,21 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def __init__( self, - tado, - zone_name, - zone_id, - zone_type, - heat_min_temp, - heat_max_temp, - heat_step, - cool_min_temp, - cool_max_temp, - cool_step, - supported_hvac_modes, - supported_fan_modes, - support_flags, - device_info, - ): + tado: TadoConnector, + zone_name: str, + zone_id: int, + zone_type: str, + supported_hvac_modes: list[HVACMode], + support_flags: ClimateEntityFeature, + device_info: dict[str, str], + heat_min_temp: float | None = None, + heat_max_temp: float | None = None, + heat_step: float | None = None, + cool_min_temp: float | None = None, + cool_max_temp: float | None = None, + cool_step: float | None = None, + supported_fan_modes: list[str] | None = None, + ) -> None: """Initialize of Tado climate entity.""" self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) @@ -276,24 +284,23 @@ def __init__( self._cool_max_temp = cool_max_temp self._cool_step = cool_step - self._target_temp = None + self._target_temp: float | None = None self._current_tado_fan_speed = CONST_FAN_OFF self._current_tado_hvac_mode = CONST_MODE_OFF self._current_tado_hvac_action = HVACAction.OFF self._current_tado_swing_mode = TADO_SWING_OFF - self._tado_zone_data = None - self._tado_geofence_data = None + self._tado_zone_data: PyTado.TadoZone = {} + self._tado_geofence_data: dict[str, str] | None = None - self._tado_zone_temp_offset = {} + self._tado_zone_temp_offset: dict[str, Any] = {} self._async_update_home_data() self._async_update_zone_data() async def async_added_to_hass(self) -> None: """Register for sensor updates.""" - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -313,12 +320,12 @@ async def async_added_to_hass(self) -> None: ) @property - def current_humidity(self): + def current_humidity(self) -> int | None: """Return the current humidity.""" return self._tado_zone_data.current_humidity @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the sensor temperature.""" return self._tado_zone_data.current_temp @@ -341,7 +348,7 @@ def hvac_action(self) -> HVACAction: ) @property - def fan_mode(self): + def fan_mode(self) -> str | None: """Return the fan setting.""" if self._ac_device: return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) @@ -352,10 +359,13 @@ def set_fan_mode(self, fan_mode: str) -> None: self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the current preset mode (home, away or auto).""" - if "presenceLocked" in self._tado_geofence_data: + if ( + self._tado_geofence_data is not None + and "presenceLocked" in self._tado_geofence_data + ): if not self._tado_geofence_data["presenceLocked"]: return PRESET_AUTO if self._tado_zone_data.is_away: @@ -363,7 +373,7 @@ def preset_mode(self): return PRESET_HOME @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return a list of available preset modes.""" if self._tado.get_auto_geofencing_supported(): return SUPPORT_PRESET_AUTO @@ -374,14 +384,14 @@ def set_preset_mode(self, preset_mode: str) -> None: self._tado.set_presence(preset_mode) @property - def target_temperature_step(self): + def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" if self._tado_zone_data.current_hvac_mode == CONST_MODE_COOL: return self._cool_step or self._heat_step return self._heat_step or self._cool_step @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" # If the target temperature will be None # if the device is performing an action @@ -389,7 +399,12 @@ def target_temperature(self): # the device is switching states return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp - def set_timer(self, temperature=None, time_period=None, requested_overlay=None): + def set_timer( + self, + temperature: float, + time_period: int, + requested_overlay: str, + ): """Set the timer on the entity, and temperature if supported.""" self._control_hvac( @@ -399,7 +414,7 @@ def set_timer(self, temperature=None, time_period=None, requested_overlay=None): overlay_mode=requested_overlay, ) - def set_temp_offset(self, offset): + def set_temp_offset(self, offset: float) -> None: """Set offset on the entity.""" _LOGGER.debug( @@ -428,7 +443,6 @@ def set_temperature(self, **kwargs: Any) -> None: def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) @property @@ -437,7 +451,7 @@ def available(self) -> bool: return self._tado_zone_data.available @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" if ( self._current_tado_hvac_mode == CONST_MODE_COOL @@ -447,10 +461,10 @@ def min_temp(self): if self._heat_min_temp is not None: return self._heat_min_temp - return self._cool_min_temp + return TADO_DEFAULT_MIN_TEMP @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" if ( self._current_tado_hvac_mode == CONST_MODE_HEAT @@ -460,17 +474,17 @@ def max_temp(self): if self._heat_max_temp is not None: return self._heat_max_temp - return self._heat_max_temp + return TADO_DEFAULT_MAX_TEMP @property - def swing_mode(self): + def swing_mode(self) -> str | None: """Active swing mode for the device.""" return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode] @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return temperature offset.""" - state_attr = self._tado_zone_temp_offset + state_attr: dict[str, Any] = self._tado_zone_temp_offset state_attr[ HA_TERMINATION_TYPE ] = self._tado_zone_data.default_overlay_termination_type @@ -484,7 +498,7 @@ def set_swing_mode(self, swing_mode: str) -> None: self._control_hvac(swing_mode=HA_TO_TADO_SWING_MODE_MAP[swing_mode]) @callback - def _async_update_zone_data(self): + def _async_update_zone_data(self) -> None: """Load tado data into zone.""" self._tado_zone_data = self._tado.data["zone"][self.zone_id] @@ -504,49 +518,49 @@ def _async_update_zone_data(self): self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode @callback - def _async_update_zone_callback(self): + def _async_update_zone_callback(self) -> None: """Load tado data and update state.""" self._async_update_zone_data() self.async_write_ha_state() @callback - def _async_update_home_data(self): + def _async_update_home_data(self) -> None: """Load tado geofencing data into zone.""" self._tado_geofence_data = self._tado.data["geofence"] @callback - def _async_update_home_callback(self): + def _async_update_home_callback(self) -> None: """Load tado data and update state.""" self._async_update_home_data() self.async_write_ha_state() - def _normalize_target_temp_for_hvac_mode(self): + def _normalize_target_temp_for_hvac_mode(self) -> None: + def adjust_temp(min_temp, max_temp) -> float | None: + if max_temp is not None and self._target_temp > max_temp: + return max_temp + if min_temp is not None and self._target_temp < min_temp: + return min_temp + return self._target_temp + # Set a target temperature if we don't have any # This can happen when we switch from Off to On if self._target_temp is None: self._target_temp = self._tado_zone_data.current_temp elif self._current_tado_hvac_mode == CONST_MODE_COOL: - if self._target_temp > self._cool_max_temp: - self._target_temp = self._cool_max_temp - elif self._target_temp < self._cool_min_temp: - self._target_temp = self._cool_min_temp + self._target_temp = adjust_temp(self._cool_min_temp, self._cool_max_temp) elif self._current_tado_hvac_mode == CONST_MODE_HEAT: - if self._target_temp > self._heat_max_temp: - self._target_temp = self._heat_max_temp - elif self._target_temp < self._heat_min_temp: - self._target_temp = self._heat_min_temp + self._target_temp = adjust_temp(self._heat_min_temp, self._heat_max_temp) def _control_hvac( self, - hvac_mode=None, - target_temp=None, - fan_mode=None, - swing_mode=None, - duration=None, - overlay_mode=None, + hvac_mode: str | None = None, + target_temp: float | None = None, + fan_mode: str | None = None, + swing_mode: str | None = None, + duration: int | None = None, + overlay_mode: str | None = None, ): """Send new target temperature to Tado.""" - if hvac_mode: self._current_tado_hvac_mode = hvac_mode @@ -605,9 +619,9 @@ def _control_hvac( # If we ended up with a timer but no duration, set a default duration if overlay_mode == CONST_OVERLAY_TIMER and duration is None: duration = ( - self._tado_zone_data.default_overlay_termination_duration + int(self._tado_zone_data.default_overlay_termination_duration) if self._tado_zone_data.default_overlay_termination_duration is not None - else "3600" + else 3600 ) _LOGGER.debug( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index ee24af29b9dcea..6f32eb1a05c9af 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -204,3 +204,11 @@ # Constants for Overlay Default settings HA_TERMINATION_TYPE = "default_overlay_type" HA_TERMINATION_DURATION = "default_overlay_seconds" + +TADO_DEFAULT_MIN_TEMP = 5 +TADO_DEFAULT_MAX_TEMP = 25 +# Constants for service calls +SERVICE_ADD_METER_READING = "add_meter_reading" +CONF_CONFIG_ENTRY = "config_entry" +CONF_READING = "reading" +ATTR_MESSAGE = "message" diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py new file mode 100644 index 00000000000000..a5c5387ce94871 --- /dev/null +++ b/homeassistant/components/tado/services.py @@ -0,0 +1,52 @@ +"""Services for the Tado integration.""" +import logging + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import selector + +from .const import ( + ATTR_MESSAGE, + CONF_CONFIG_ENTRY, + CONF_READING, + DATA, + DOMAIN, + SERVICE_ADD_METER_READING, +) + +_LOGGER = logging.getLogger(__name__) +SCHEMA_ADD_METER_READING = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_READING): vol.Coerce(int), + } +) + + +@callback +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Tado integration.""" + + async def add_meter_reading(call: ServiceCall) -> None: + """Send meter reading to Tado.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + reading: int = call.data[CONF_READING] + _LOGGER.debug("Add meter reading %s", reading) + + tadoconnector = hass.data[DOMAIN][entry_id][DATA] + response: dict = await hass.async_add_executor_job( + tadoconnector.set_meter_reading, call.data[CONF_READING] + ) + + if ATTR_MESSAGE in response: + raise HomeAssistantError(response[ATTR_MESSAGE]) + + hass.services.async_register( + DOMAIN, SERVICE_ADD_METER_READING, add_meter_reading, SCHEMA_ADD_METER_READING + ) diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 0f66798f864c5e..a5cfb919a41d42 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -61,3 +61,18 @@ set_climate_temperature_offset: max: 10 step: 0.01 unit_of_measurement: "°" + +add_meter_reading: + fields: + config_entry: + required: true + selector: + config_entry: + integration: tado + reading: + required: true + selector: + number: + mode: box + min: 0 + step: 1 diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index d50d14905669d3..267cbbe6fee39e 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -122,6 +122,20 @@ "description": "Offset you would like (depending on your device)." } } + }, + "add_meter_reading": { + "name": "Add meter readings", + "description": "Add meter readings to Tado Energy IQ.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "Config entry to add meter readings to." + }, + "reading": { + "name": "Reading", + "description": "Reading in m³ or kWh without decimals." + } + } } }, "issues": { diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index b7e68bbb1003db..cdbc041f535d4d 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -2,6 +2,7 @@ import logging from typing import Any +import PyTado import voluptuous as vol from homeassistant.components.water_heater import ( @@ -15,6 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TadoConnector from .const import ( CONST_HVAC_HEAT, CONST_MODE_AUTO, @@ -27,6 +29,8 @@ DATA, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED, + TADO_DEFAULT_MAX_TEMP, + TADO_DEFAULT_MIN_TEMP, TYPE_HOT_WATER, ) from .entity import TadoZoneEntity @@ -78,7 +82,7 @@ async def async_setup_entry( async_add_entities(entities, True) -def _generate_entities(tado): +def _generate_entities(tado: TadoConnector) -> list[WaterHeaterEntity]: """Create all water heater entities.""" entities = [] @@ -90,7 +94,7 @@ def _generate_entities(tado): return entities -def create_water_heater_entity(tado, name: str, zone_id: int, zone: str): +def create_water_heater_entity(tado: TadoConnector, name: str, zone_id: int, zone: str): """Create a Tado water heater device.""" capabilities = tado.get_capabilities(zone_id) @@ -125,15 +129,14 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): def __init__( self, - tado, - zone_name, - zone_id, - supports_temperature_control, - min_temp, - max_temp, - ): + tado: TadoConnector, + zone_name: str, + zone_id: int, + supports_temperature_control: bool, + min_temp: float | None = None, + max_temp: float | None = None, + ) -> None: """Initialize of Tado water heater entity.""" - self._tado = tado super().__init__(zone_name, tado.home_id, zone_id) @@ -143,10 +146,10 @@ def __init__( self._device_is_active = False self._supports_temperature_control = supports_temperature_control - self._min_temperature = min_temp - self._max_temperature = max_temp + self._min_temperature = min_temp or TADO_DEFAULT_MIN_TEMP + self._max_temperature = max_temp or TADO_DEFAULT_MAX_TEMP - self._target_temp = None + self._target_temp: float | None = None self._attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE if self._supports_temperature_control: @@ -154,11 +157,10 @@ def __init__( self._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._tado_zone_data = None + self._tado_zone_data: PyTado.TadoZone = {} async def async_added_to_hass(self) -> None: """Register for sensor updates.""" - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -171,27 +173,27 @@ async def async_added_to_hass(self) -> None: self._async_update_data() @property - def current_operation(self): + def current_operation(self) -> str | None: """Return current readable operation mode.""" return WATER_HEATER_MAP_TADO.get(self._current_tado_hvac_mode) @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._tado_zone_data.target_temp @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool: """Return true if away mode is on.""" return self._tado_zone_data.is_away @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" return self._min_temperature @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" return self._max_temperature @@ -208,7 +210,7 @@ def set_operation_mode(self, operation_mode: str) -> None: self._control_heater(hvac_mode=mode) - def set_timer(self, time_period, temperature=None): + def set_timer(self, time_period: int, temperature: float | None = None): """Set the timer on the entity, and temperature if supported.""" if not self._supports_temperature_control and temperature is not None: temperature = None @@ -234,21 +236,25 @@ def set_temperature(self, **kwargs: Any) -> None: self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) @callback - def _async_update_callback(self): + def _async_update_callback(self) -> None: """Load tado data and update state.""" self._async_update_data() self.async_write_ha_state() @callback - def _async_update_data(self): + def _async_update_data(self) -> None: """Load tado data.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) self._tado_zone_data = self._tado.data["zone"][self.zone_id] self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - def _control_heater(self, hvac_mode=None, target_temp=None, duration=None): + def _control_heater( + self, + hvac_mode: str | None = None, + target_temp: float | None = None, + duration: int | None = None, + ): """Send new target temperature.""" - if hvac_mode: self._current_tado_hvac_mode = hvac_mode diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index c28ebf4aab2ca5..c2d91f20b8a4a5 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -62,8 +62,11 @@ async def async_setup(self) -> None: station = await self._tankerkoenig.station_details(station_id) except TankerkoenigInvalidKeyError as err: raise ConfigEntryAuthFailed(err) from err - except (TankerkoenigError, TankerkoenigConnectionError) as err: + except TankerkoenigConnectionError as err: raise ConfigEntryNotReady(err) from err + except TankerkoenigError as err: + _LOGGER.error("Error when adding station %s %s", station_id, err) + continue self.stations[station_id] = station diff --git a/homeassistant/components/technove/__init__.py b/homeassistant/components/technove/__init__.py index a235f98433b139..999cb2e2f344a2 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -8,10 +8,7 @@ from .const import DOMAIN from .coordinator import TechnoVEDataUpdateCoordinator -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.SENSOR, -] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/technove/helpers.py b/homeassistant/components/technove/helpers.py new file mode 100644 index 00000000000000..43bd8f04794482 --- /dev/null +++ b/homeassistant/components/technove/helpers.py @@ -0,0 +1,40 @@ +"""Helpers for TechnoVE.""" +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from typing import Any, Concatenate, ParamSpec, TypeVar + +from technove import TechnoVEConnectionError, TechnoVEError + +from homeassistant.exceptions import HomeAssistantError + +from .entity import TechnoVEEntity + +_TechnoVEEntityT = TypeVar("_TechnoVEEntityT", bound=TechnoVEEntity) +_P = ParamSpec("_P") + + +def technove_exception_handler( + func: Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, Any]], +) -> Callable[Concatenate[_TechnoVEEntityT, _P], Coroutine[Any, Any, None]]: + """Decorate TechnoVE calls to handle TechnoVE exceptions. + + A decorator that wraps the passed in function, catches TechnoVE errors, + and handles the availability of the device in the data coordinator. + """ + + async def handler( + self: _TechnoVEEntityT, *args: _P.args, **kwargs: _P.kwargs + ) -> None: + try: + await func(self, *args, **kwargs) + + except TechnoVEConnectionError as error: + self.coordinator.last_update_success = False + self.coordinator.async_update_listeners() + raise HomeAssistantError("Error communicating with TechnoVE API") from error + + except TechnoVEError as error: + raise HomeAssistantError("Invalid response from TechnoVE API") from error + + return handler diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json index f38bf61d8ed3fc..1e7550c884295d 100644 --- a/homeassistant/components/technove/strings.json +++ b/homeassistant/components/technove/strings.json @@ -68,6 +68,11 @@ "high_charge_period": "High charge period" } } + }, + "switch": { + "auto_charge": { + "name": "Auto charge" + } } } } diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py new file mode 100644 index 00000000000000..3ee7f1c302d43d --- /dev/null +++ b/homeassistant/components/technove/switch.py @@ -0,0 +1,86 @@ +"""Support for TechnoVE switches.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from technove import Station as TechnoVEStation, TechnoVE + +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 .const import DOMAIN +from .coordinator import TechnoVEDataUpdateCoordinator +from .entity import TechnoVEEntity +from .helpers import technove_exception_handler + + +@dataclass(frozen=True, kw_only=True) +class TechnoVESwitchDescription(SwitchEntityDescription): + """Describes TechnoVE binary sensor entity.""" + + is_on_fn: Callable[[TechnoVEStation], bool] + turn_on_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] + turn_off_fn: Callable[[TechnoVE], Awaitable[dict[str, Any]]] + + +SWITCHES = [ + TechnoVESwitchDescription( + key="auto_charge", + translation_key="auto_charge", + entity_category=EntityCategory.CONFIG, + is_on_fn=lambda station: station.info.auto_charge, + turn_on_fn=lambda technoVE: technoVE.set_auto_charge(enabled=True), + turn_off_fn=lambda technoVE: technoVE.set_auto_charge(enabled=False), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up TechnoVE switch based on a config entry.""" + coordinator: TechnoVEDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TechnoVESwitchEntity(coordinator, description) for description in SWITCHES + ) + + +class TechnoVESwitchEntity(TechnoVEEntity, SwitchEntity): + """Defines a TechnoVE switch entity.""" + + entity_description: TechnoVESwitchDescription + + def __init__( + self, + coordinator: TechnoVEDataUpdateCoordinator, + description: TechnoVESwitchDescription, + ) -> None: + """Initialize a TechnoVE switch entity.""" + self.entity_description = description + super().__init__(coordinator, description.key) + + @property + def is_on(self) -> bool: + """Return the state of the TechnoVE switch.""" + + return self.entity_description.is_on_fn(self.coordinator.data) + + @technove_exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the TechnoVE switch.""" + await self.entity_description.turn_on_fn(self.coordinator.technove) + await self.coordinator.async_request_refresh() + + @technove_exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the TechnoVE switch.""" + await self.entity_description.turn_off_fn(self.coordinator.technove) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 7efa25fa245c8f..645e25d4e85c2e 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -1,4 +1,5 @@ """Tedee sensor entities.""" + from collections.abc import Callable from dataclasses import dataclass @@ -58,21 +59,17 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = hass.data[DOMAIN][entry.entry_id] - for entity_description in ENTITIES: - async_add_entities( - [ - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - ] - ) + async_add_entities( + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + for entity_description in ENTITIES + ) def _async_add_new_lock(lock_id: int) -> None: lock = coordinator.data[lock_id] async_add_entities( - [ - TedeeBinarySensorEntity(lock, coordinator, entity_description) - for entity_description in ENTITIES - ] + TedeeBinarySensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES ) coordinator.new_lock_callbacks.append(_async_add_new_lock) diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 075a4c998eaef1..7c8c7b4c3ab93e 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Tedee integration.""" + from collections.abc import Mapping from typing import Any @@ -83,14 +84,24 @@ async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required( - CONF_LOCAL_ACCESS_TOKEN, - default=entry_data[CONF_LOCAL_ACCESS_TOKEN], - ): str, - } - ), - ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry + + if not user_input: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCAL_ACCESS_TOKEN, + default=self.reauth_entry.data[CONF_LOCAL_ACCESS_TOKEN], + ): str, + } + ), + ) + return await self.async_step_user(user_input) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 1776e3b7ab201a..a3e29e1b40f886 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "requirements": ["pytedee-async==0.2.13"] + "requirements": ["pytedee-async==0.2.15"] } diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 9880f73746dd09..225686f6b18af5 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -1,4 +1,5 @@ """Tedee sensor entities.""" + from collections.abc import Callable from dataclasses import dataclass @@ -11,7 +12,7 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,15 +34,17 @@ class TedeeSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda lock: lock.battery_level, + entity_category=EntityCategory.DIAGNOSTIC, ), TedeeSensorEntityDescription( key="pullspring_duration", translation_key="pullspring_duration", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, icon="mdi:timer-lock-open", value_fn=lambda lock: lock.duration_pullspring, + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -54,21 +57,17 @@ async def async_setup_entry( """Set up the Tedee sensor entity.""" coordinator = hass.data[DOMAIN][entry.entry_id] - for entity_description in ENTITIES: - async_add_entities( - [ - TedeeSensorEntity(lock, coordinator, entity_description) - for lock in coordinator.data.values() - ] - ) + async_add_entities( + TedeeSensorEntity(lock, coordinator, entity_description) + for lock in coordinator.data.values() + for entity_description in ENTITIES + ) def _async_add_new_lock(lock_id: int) -> None: lock = coordinator.data[lock_id] async_add_entities( - [ - TedeeSensorEntity(lock, coordinator, entity_description) - for entity_description in ENTITIES - ] + TedeeSensorEntity(lock, coordinator, entity_description) + for entity_description in ENTITIES ) coordinator.new_lock_callbacks.append(_async_add_new_lock) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 1d71e055e2eba4..2ba7752a85fdd9 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -602,12 +602,8 @@ def _make_row_inline_keyboard(row_keyboard): if keys: params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( [[key.strip() for key in row.split(",")] for row in keys], - resize_keyboard=data[ATTR_RESIZE_KEYBOARD] - if ATTR_RESIZE_KEYBOARD in data - else False, - one_time_keyboard=data[ATTR_ONE_TIME_KEYBOARD] - if ATTR_ONE_TIME_KEYBOARD in data - else False, + resize_keyboard=data.get(ATTR_RESIZE_KEYBOARD, False), + one_time_keyboard=data.get(ATTR_ONE_TIME_KEYBOARD, False), ) else: params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True) diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 060b90a7d706ce..33910f6ead111b 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -94,7 +94,7 @@ async def async_step_auth(self, user_input=None): auth_url = await self.hass.async_add_executor_job(self._get_auth_url) if not auth_url: return self.async_abort(reason="unknown_authorize_url_generation") - except asyncio.TimeoutError: + except TimeoutError: return self.async_abort(reason="authorize_url_timeout") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error generating auth url") diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index 5ac2b7efa671c5..047d58d9208991 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -74,21 +74,28 @@ async def _attach_triggers(self, start_event=None) -> None: if start_event is not None: self._unsub_start = None + if self._script: + action: Callable = self._handle_triggered_with_script + else: + action = self._handle_triggered + self._unsub_trigger = await trigger_helper.async_initialize_triggers( self.hass, self.config[CONF_TRIGGER], - self._handle_triggered, + action, DOMAIN, self.name, self.logger.log, start_event is not None, ) - async def _handle_triggered(self, run_variables, context=None): - if self._script: - script_result = await self._script.async_run(run_variables, context) - if script_result: - run_variables = script_result.variables + async def _handle_triggered_with_script(self, run_variables, context=None): + if script_result := await self._script.async_run(run_variables, context): + run_variables = script_result.variables + self._handle_triggered(run_variables, context) + + @callback + def _handle_triggered(self, run_variables, context=None): self.async_set_updated_data( {"run_variables": run_variables, "context": context} ) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 79cd028972465b..6122f4c9db5b0c 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -20,7 +20,7 @@ "title": "Template sensor" }, "user": { - "description": "This helper allow you to create helper entities that define their state using a template.", + "description": "This helper allows you to create helper entities that define their state using a template.", "menu_options": { "binary_sensor": "Template a binary sensor", "sensor": "Template a sensor" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index e9ac03c69e1f9d..d21d9a75e0b613 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -50,7 +50,7 @@ class WallConnectorBinarySensorDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] @@ -60,7 +60,7 @@ async def async_setup_entry( for description in WALL_CONNECTOR_SENSORS ] - async_add_devices(all_entities) + async_add_entities(all_entities) class WallConnectorBinarySensorEntity(WallConnectorEntity, BinarySensorEntity): diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index da1e974f6a0ef8..bba01f8692dc59 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -172,7 +172,7 @@ class WallConnectorSensorDescription( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Create the Wall Connector sensor devices.""" wall_connector_data = hass.data[DOMAIN][config_entry.entry_id] @@ -182,7 +182,7 @@ async def async_setup_entry( for description in WALL_CONNECTOR_SENSORS ] - async_add_devices(all_entities) + async_add_entities(all_entities) class WallConnectorSensorEntity(WallConnectorEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 4f12b4a3111d7d..35e8ccd3bcfaf2 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -2,8 +2,8 @@ from datetime import timedelta from typing import Any +from tesla_fleet_api import VehicleSpecific from tesla_fleet_api.exceptions import TeslaFleetError, VehicleOffline -from tesla_fleet_api.vehiclespecific import VehicleSpecific from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json new file mode 100644 index 00000000000000..a4521b52945757 --- /dev/null +++ b/homeassistant/components/teslemetry/icons.json @@ -0,0 +1,18 @@ +{ + "entity": { + "climate": { + "driver_temp": { + "state_attributes": { + "preset_mode": { + "state": { + "off": "mdi:power", + "keep": "mdi:fan", + "dog": "mdi:dog", + "camp": "mdi:tent" + } + } + } + } + } + } +} diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index c76ac6fb63a71c..ab2d52f329d7cd 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.2.3"] + "requirements": ["tesla-fleet-api==0.4.6"] } diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index 594098cddfe17f..34d80b4f932ab6 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -41,6 +41,7 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): key="charge_state_charging_state", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, is_on=lambda x: x == "Charging", + entity_registry_enabled_default=False, ), TessieBinarySensorEntityDescription( key="charge_state_preconditioning_enabled", @@ -62,17 +63,14 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): ), TessieBinarySensorEntityDescription( key="climate_state_auto_seat_climate_left", - device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, ), TessieBinarySensorEntityDescription( key="climate_state_auto_seat_climate_right", - device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, ), TessieBinarySensorEntityDescription( key="climate_state_auto_steering_wheel_heat", - device_class=BinarySensorDeviceClass.HEAT, entity_category=EntityCategory.DIAGNOSTIC, ), TessieBinarySensorEntityDescription( @@ -94,7 +92,7 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): ), TessieBinarySensorEntityDescription( key="vehicle_state_is_user_present", - device_class=BinarySensorDeviceClass.PRESENCE, + device_class=BinarySensorDeviceClass.OCCUPANCY, ), TessieBinarySensorEntityDescription( key="vehicle_state_tpms_soft_warning_fl", diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py index 86065d389a4eaf..62bf6f79a6e56c 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -31,22 +31,17 @@ class TessieButtonEntityDescription(ButtonEntityDescription): DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( - TessieButtonEntityDescription(key="wake", func=lambda: wake, icon="mdi:sleep-off"), + TessieButtonEntityDescription(key="wake", func=lambda: wake), + TessieButtonEntityDescription(key="flash_lights", func=lambda: flash_lights), + TessieButtonEntityDescription(key="honk", func=lambda: honk), TessieButtonEntityDescription( - key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" - ), - TessieButtonEntityDescription(key="honk", func=lambda: honk, icon="mdi:bullhorn"), - TessieButtonEntityDescription( - key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" + key="trigger_homelink", func=lambda: trigger_homelink ), TessieButtonEntityDescription( key="enable_keyless_driving", func=lambda: enable_keyless_driving, - icon="mdi:car-key", - ), - TessieButtonEntityDescription( - key="boombox", func=lambda: boombox, icon="mdi:volume-high" ), + TessieButtonEntityDescription(key="boombox", func=lambda: boombox), ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py index 591d4652274486..8ec063bf47cf34 100644 --- a/homeassistant/components/tessie/const.py +++ b/homeassistant/components/tessie/const.py @@ -68,3 +68,13 @@ class TessieChargeCableLockStates(StrEnum): ENGAGED = "Engaged" DISENGAGED = "Disengaged" + + +TessieChargeStates = { + "Starting": "starting", + "Charging": "charging", + "Stopped": "stopped", + "Complete": "complete", + "Disconnected": "disconnected", + "NoPower": "no_power", +} diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py index bfedd7eb43d6d9..718a705095307f 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -53,7 +53,7 @@ def get(self, key: str | None = None, default: Any | None = None) -> Any: return self.coordinator.data.get(key or self.key, default) async def run( - self, func: Callable[..., Awaitable[dict[str, bool | str]]], **kargs: Any + self, func: Callable[..., Awaitable[dict[str, Any]]], **kargs: Any ) -> None: """Run a tessie_api function and handle exceptions.""" try: @@ -66,8 +66,13 @@ async def run( except ClientResponseError as e: raise HomeAssistantError from e if response["result"] is False: + name: str = getattr(self, "name", self.entity_id) + reason: str = response.get("reason", "unknown") raise HomeAssistantError( - response.get("reason", "An unknown issue occurred") + reason.replace("_", " "), + translation_domain=DOMAIN, + translation_key=reason.replace(" ", "_"), + translation_placeholders={"name": name}, ) def set(self, *args: Any) -> None: diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json new file mode 100644 index 00000000000000..0b1051e662f8bd --- /dev/null +++ b/homeassistant/components/tessie/icons.json @@ -0,0 +1,212 @@ +{ + "entity": { + "binary_sensor": { + "charge_state_scheduled_charging_pending": { + "default": "mdi:battery-clock" + }, + "charge_state_trip_charging": { + "default": "mdi:car-clock" + }, + "climate_state_auto_seat_climate_left": { + "default": "mdi:car-seat", + "state": { + "on": "mdi:car-seat-heater" + } + }, + "climate_state_auto_seat_climate_right": { + "default": "mdi:car-seat", + "state": { + "on": "mdi:car-seat-heater" + } + }, + "climate_state_auto_steering_wheel_heat": { + "default": "mdi:steering" + }, + "vehicle_state_dashcam_state": { + "default": "mdi:camera-off", + "state": { + "on": "mdi:camera" + } + }, + "vehicle_state_is_user_present": { + "default": "mdi:account-outline", + "state": { + "on": "mdi:account" + } + }, + "vehicle_state_tpms_soft_warning_fl": { + "default": "mdi:tire", + "state": { + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_fr": { + "default": "mdi:tire", + "state": { + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_rl": { + "default": "mdi:tire", + "state": { + "on": "mdi:car-tire-alert" + } + }, + "vehicle_state_tpms_soft_warning_rr": { + "default": "mdi:tire", + "state": { + "on": "mdi:car-tire-alert" + } + } + }, + "button": { + "wake": { + "default": "mdi:sleep-off" + }, + "flash_lights": { + "default": "mdi:flashlight" + }, + "honk": { + "default": "mdi:bullhorn" + }, + "trigger_homelink": { + "default": "mdi:garage" + }, + "enable_keyless_driving": { + "default": "mdi:car-key" + }, + "boombox": { + "default": "mdi:volume-high" + } + }, + "climate": { + "primary": { + "state_attributes": { + "preset_mode": { + "state": { + "off": "mdi:fan", + "on": "mdi:thermometer-auto", + "dog": "mdi:paw", + "camp": "mdi:tent" + } + } + } + } + }, + "device_tracker": { + "location": { + "default": "mdi:car", + "state": { + "not_home": "mdi:car-arrow-right" + } + }, + "route": { + "default": "mdi:map-marker", + "state": { + "home": "mdi:home-map-marker" + } + } + }, + "select": { + "climate_state_seat_heater_left": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_right": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_rear_left": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_rear_center": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_rear_right": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_third_row_left": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + }, + "climate_state_seat_heater_third_row_right": { + "default": "mdi:car-seat", + "state": { + "low": "mdi:car-seat-heater", + "medium": "mdi:car-seat-heater", + "high": "mdi:car-seat-heater" + } + } + }, + "sensor": { + "charge_state_charging_state": { + "default": "mdi:ev-station" + }, + "charge_state_minutes_to_full_charge": { + "default": "mdi:clock-end" + }, + "drive_state_shift_state": { + "default": "mdi:car-shift-pattern" + }, + "vehicle_state_odometer": { + "default": "mdi:counter" + }, + "drive_state_active_route_traffic_minutes_delay": { + "default": "mdi:clock-alert-outline" + }, + "drive_state_active_route_miles_to_arrival": { + "default": "mdi:map-marker-distance" + }, + "drive_state_active_route_minutes_to_arrival": { + "default": "mdi:timer-marker-outline" + }, + "drive_state_active_route_destination": { + "default": "mdi:map-marker" + } + }, + "switch": { + "climate_state_defrost_mode": { + "default": "mdi:car-defrost-front" + }, + "vehicle_state_sentry_mode": { + "default": "mdi:radiobox-marked" + }, + "climate_state_steering_wheel_heater": { + "default": "mdi:steering" + }, + "vehicle_state_valet_mode": { + "default": "mdi:bow-tie" + }, + "charge_state_charge_enable_request": { + "default": "mdi:ev-plug-ccs2" + } + } + } +} diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py index 1a0d879cd795c0..9a27e95c73e428 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -3,9 +3,15 @@ from typing import Any -from tessie_api import lock, open_unlock_charge_port, unlock - -from homeassistant.components.lock import LockEntity +from tessie_api import ( + disable_speed_limit, + enable_speed_limit, + lock, + open_unlock_charge_port, + unlock, +) + +from homeassistant.components.lock import ATTR_CODE, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -24,7 +30,7 @@ async def async_setup_entry( async_add_entities( klass(vehicle.state_coordinator) - for klass in (TessieLockEntity, TessieCableLockEntity) + for klass in (TessieLockEntity, TessieCableLockEntity, TessieSpeedLimitEntity) for vehicle in data ) @@ -55,6 +61,38 @@ async def async_unlock(self, **kwargs: Any) -> None: self.set((self.key, False)) +class TessieSpeedLimitEntity(TessieEntity, LockEntity): + """Speed Limit with PIN entity for Tessie.""" + + _attr_code_format = r"^\d\d\d\d$" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_speed_limit_mode_active") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Enable speed limit with pin.""" + code: str | None = kwargs.get(ATTR_CODE) + if code: + await self.run(enable_speed_limit, pin=code) + self.set((self.key, True)) + + async def async_unlock(self, **kwargs: Any) -> None: + """Disable speed limit with pin.""" + code: str | None = kwargs.get(ATTR_CODE) + if code: + await self.run(disable_speed_limit, pin=code) + self.set((self.key, False)) + + class TessieCableLockEntity(TessieEntity, LockEntity): """Cable Lock entity for Tessie.""" diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py index ae9e06b2b359f3..3e5a0a60aa3ed5 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -32,7 +32,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance -from .const import DOMAIN +from .const import DOMAIN, TessieChargeStates from .coordinator import TessieStateUpdateCoordinator from .entity import TessieEntity @@ -54,6 +54,12 @@ class TessieSensorEntityDescription(SensorEntityDescription): DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="charge_state_charging_state", + options=list(TessieChargeStates.values()), + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: TessieChargeStates[cast(str, value)], + ), TessieSensorEntityDescription( key="charge_state_usable_battery_level", state_class=SensorStateClass.MEASUREMENT, @@ -107,6 +113,22 @@ class TessieSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DISTANCE, suggested_display_precision=1, ), + TessieSensorEntityDescription( + key="charge_state_est_battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), + TessieSensorEntityDescription( + key="charge_state_ideal_battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + entity_registry_enabled_default=False, + ), TessieSensorEntityDescription( key="drive_state_speed", state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +144,6 @@ class TessieSensorEntityDescription(SensorEntityDescription): ), TessieSensorEntityDescription( key="drive_state_shift_state", - icon="mdi:car-shift-pattern", options=["p", "d", "r", "n"], device_class=SensorDeviceClass.ENUM, value_fn=lambda x: x.lower() if isinstance(x, str) else x, @@ -231,7 +252,6 @@ class TessieSensorEntityDescription(SensorEntityDescription): ), TessieSensorEntityDescription( key="drive_state_active_route_destination", - icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), ) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index 8340557843dcbf..62de4f276f42b6 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -59,6 +59,9 @@ }, "charge_state_charge_port_latch": { "name": "Charge cable lock" + }, + "vehicle_state_speed_limit_mode_active": { + "name": "Speed limit" } }, "media_player": { @@ -67,6 +70,17 @@ } }, "sensor": { + "charge_state_charging_state": { + "name": "Charging", + "state": { + "starting": "Starting", + "charging": "Charging", + "disconnected": "Disconnected", + "stopped": "Stopped", + "complete": "Complete", + "no_power": "No power" + } + }, "charge_state_usable_battery_level": { "name": "Battery level" }, @@ -88,6 +102,12 @@ "charge_state_battery_range": { "name": "Battery range" }, + "charge_state_est_battery_range": { + "name": "Battery range estimate" + }, + "charge_state_ideal_battery_range": { + "name": "Battery range ideal" + }, "charge_state_minutes_to_full_charge": { "name": "Time to full charge" }, @@ -156,8 +176,12 @@ "charge_state_charge_port_door_open": { "name": "Charge port door" }, - "vehicle_state_ft": { "name": "Frunk" }, - "vehicle_state_rt": { "name": "Trunk" } + "vehicle_state_ft": { + "name": "Frunk" + }, + "vehicle_state_rt": { + "name": "Trunk" + } }, "select": { "climate_state_seat_heater_left": { @@ -258,7 +282,7 @@ "climate_state_auto_seat_climate_right": { "name": "Auto seat climate right" }, - "climate_state_auto_steering_wheel_heater": { + "climate_state_auto_steering_wheel_heat": { "name": "Auto steering wheel heater" }, "climate_state_cabin_overheat_protection": { @@ -311,12 +335,24 @@ } }, "button": { - "wake": { "name": "Wake" }, - "flash_lights": { "name": "Flash lights" }, - "honk": { "name": "Honk horn" }, - "trigger_homelink": { "name": "Homelink" }, - "enable_keyless_driving": { "name": "Keyless driving" }, - "boombox": { "name": "Play fart" } + "wake": { + "name": "Wake" + }, + "flash_lights": { + "name": "Flash lights" + }, + "honk": { + "name": "Honk horn" + }, + "trigger_homelink": { + "name": "Homelink" + }, + "enable_keyless_driving": { + "name": "Keyless driving" + }, + "boombox": { + "name": "Play fart" + } }, "switch": { "charge_state_charge_enable_request": { @@ -353,6 +389,24 @@ } }, "exceptions": { + "unknown": { + "message": "An unknown issue occured changing {name}." + }, + "not_supported": { + "message": "{name} is not supported." + }, + "cable_connected": { + "message": "Charge cable is connected." + }, + "already_active": { + "message": "{name} is already active." + }, + "already_inactive": { + "message": "{name} is already inactive." + }, + "incorrect_pin": { + "message": "Incorrect pin for {name}." + }, "no_cable": { "message": "Insert cable to lock" } diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py index 595c44e11bea78..b8ac2ede52b157 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -45,31 +45,26 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): key="charge_state_charge_enable_request", on_func=lambda: start_charging, off_func=lambda: stop_charging, - icon="mdi:ev-station", ), TessieSwitchEntityDescription( key="climate_state_defrost_mode", on_func=lambda: start_defrost, off_func=lambda: stop_defrost, - icon="mdi:snowflake", ), TessieSwitchEntityDescription( key="vehicle_state_sentry_mode", on_func=lambda: enable_sentry_mode, off_func=lambda: disable_sentry_mode, - icon="mdi:shield-car", ), TessieSwitchEntityDescription( key="vehicle_state_valet_mode", on_func=lambda: enable_valet_mode, off_func=lambda: disable_valet_mode, - icon="mdi:car-key", ), TessieSwitchEntityDescription( key="climate_state_steering_wheel_heater", on_func=lambda: start_steering_wheel_heater, off_func=lambda: stop_steering_wheel_heater, - icon="mdi:steering", ), ) diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py index 7e5999b7f02307..ea6a6f22d2b954 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -63,7 +63,7 @@ async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - async_add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the TFIAC climate device.""" @@ -73,7 +73,7 @@ async def async_setup_platform( except futures.TimeoutError: _LOGGER.error("Unable to connect to %s", config[CONF_HOST]) return - async_add_devices([TfiacClimate(hass, tfiac_client)]) + async_add_entities([TfiacClimate(hass, tfiac_client)]) class TfiacClimate(ClimateEntity): diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 817df22d6e1827..51348afb0a4a3d 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -13,6 +13,10 @@ { "local_name": "TP96*", "connectable": false + }, + { + "local_name": "TP97*", + "connectable": false } ], "codeowners": ["@bdraco", "@h3ss"], @@ -20,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.9.0"] + "requirements": ["thermopro-ble==0.10.0"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 06005d7e4ed07d..b9568a979fab3f 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -136,7 +136,7 @@ async def async_update(self): async with asyncio.timeout(DEFAULT_TIMEOUT): response = await session.get(self._url, headers=self._headers) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Error while accessing: %s", self._url) return None diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 65d4c9d044ccb7..19d8fa76c66e09 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["zeroconf"], "documentation": "https://www.home-assistant.io/integrations/thread", + "import_executor": true, "integration_type": "service", "iot_class": "local_polling", "requirements": ["python-otbr-api==2.6.0", "pyroute2==0.7.5"], diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 6bd68e17c4d272..52db842178125b 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -1,5 +1,4 @@ """Support for Tibber.""" -import asyncio import logging import aiohttp @@ -55,7 +54,7 @@ async def _close(event: Event) -> None: await tibber_connection.update_info() except ( - asyncio.TimeoutError, + TimeoutError, aiohttp.ClientError, tibber.RetryableHttpException, ) as err: diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 3fb426d6b11c47..8c926c5cc81def 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -1,7 +1,6 @@ """Adds config flow for Tibber integration.""" from __future__ import annotations -import asyncio from typing import Any import aiohttp @@ -46,7 +45,7 @@ async def async_step_user( try: await tibber_connection.update_info() - except asyncio.TimeoutError: + except TimeoutError: errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT except tibber.InvalidLogin: errors[CONF_ACCESS_TOKEN] = ERR_TOKEN diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 270528fc4e9ccc..997afa6235956f 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -1,7 +1,6 @@ """Support for Tibber notifications.""" from __future__ import annotations -import asyncio from collections.abc import Callable import logging from typing import Any @@ -41,5 +40,5 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: await self._notify(title=title, message=message) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.error("Timeout sending message with Tibber") diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 467cd2bfd77241..a2bd8d26f75fe5 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -1,7 +1,6 @@ """Support for Tibber sensors.""" from __future__ import annotations -import asyncio import datetime from datetime import timedelta import logging @@ -61,132 +60,161 @@ PARALLEL_UPDATES = 0 +RT_SENSORS_UNIQUE_ID_MIGRATION = { + "accumulated_consumption_last_hour": "accumulated consumption current hour", + "accumulated_production_last_hour": "accumulated production current hour", + "current_l1": "current L1", + "current_l2": "current L2", + "current_l3": "current L3", + "estimated_hour_consumption": "Estimated consumption current hour", +} + +RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE = { + # simple migration can be done by replacing " " with "_" + "accumulated_consumption", + "accumulated_cost", + "accumulated_production", + "accumulated_reward", + "average_power", + "last_meter_consumption", + "last_meter_production", + "max_power", + "min_power", + "power_factor", + "power_production", + "signal_strength", + "voltage_phase1", + "voltage_phase2", + "voltage_phase3", +} + + RT_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="averagePower", - name="average power", + translation_key="average_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="power", - name="power", + translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="powerProduction", - name="power production", + translation_key="power_production", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="minPower", - name="min power", + translation_key="min_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="maxPower", - name="max power", + translation_key="max_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, ), SensorEntityDescription( key="accumulatedConsumption", - name="accumulated consumption", + translation_key="accumulated_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedConsumptionLastHour", - name="accumulated consumption current hour", + translation_key="accumulated_consumption_last_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="estimatedHourConsumption", - name="Estimated consumption current hour", + translation_key="estimated_hour_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="accumulatedProduction", - name="accumulated production", + translation_key="accumulated_production", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedProductionLastHour", - name="accumulated production current hour", + translation_key="accumulated_production_last_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterConsumption", - name="last meter consumption", + translation_key="last_meter_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="lastMeterProduction", - name="last meter production", + translation_key="last_meter_production", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="voltagePhase1", - name="voltage phase1", + translation_key="voltage_phase1", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase2", - name="voltage phase2", + translation_key="voltage_phase2", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltagePhase3", - name="voltage phase3", + translation_key="voltage_phase3", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL1", - name="current L1", + translation_key="current_l1", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL2", - name="current L2", + translation_key="current_l2", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="currentL3", - name="current L3", + translation_key="current_l3", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="signalStrength", - name="signal strength", + translation_key="signal_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, state_class=SensorStateClass.MEASUREMENT, @@ -194,19 +222,19 @@ ), SensorEntityDescription( key="accumulatedReward", - name="accumulated reward", + translation_key="accumulated_reward", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedCost", - name="accumulated cost", + translation_key="accumulated_cost", device_class=SensorDeviceClass.MONETARY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="powerFactor", - name="power factor", + translation_key="power_factor", device_class=SensorDeviceClass.POWER_FACTOR, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -216,23 +244,23 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="month_cost", - name="Monthly cost", + translation_key="month_cost", device_class=SensorDeviceClass.MONETARY, ), SensorEntityDescription( key="peak_hour", - name="Monthly peak hour consumption", + translation_key="peak_hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), SensorEntityDescription( key="peak_hour_time", - name="Time of max hour consumption", + translation_key="peak_hour_time", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="month_cons", - name="Monthly net consumption", + translation_key="month_cons", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -255,7 +283,7 @@ async def async_setup_entry( for home in tibber_connection.get_homes(only_active=False): try: await home.update_info() - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady() from err except aiohttp.ClientError as err: @@ -305,6 +333,8 @@ async def async_setup_entry( class TibberSensor(SensorEntity): """Representation of a generic Tibber sensor.""" + _attr_has_entity_name = True + def __init__( self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any ) -> None: @@ -335,6 +365,9 @@ def device_info(self) -> DeviceInfo: class TibberSensorElPrice(TibberSensor): """Representation of a Tibber sensor for el price.""" + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_translation_key = "electricity_price" + def __init__(self, tibber_home: tibber.TibberHome) -> None: """Initialize the sensor.""" super().__init__(tibber_home=tibber_home) @@ -355,8 +388,6 @@ def __init__(self, tibber_home: tibber.TibberHome) -> None: "off_peak_2": None, } self._attr_icon = ICON - self._attr_name = f"Electricity price {self._home_name}" - self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_unique_id = self._tibber_home.home_id self._model = "Price Sensor" @@ -396,7 +427,7 @@ async def _fetch_data(self) -> None: _LOGGER.debug("Fetching data") try: await self._tibber_home.update_info_and_price_info() - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): return data = self._tibber_home.info["viewer"]["home"] self._attr_extra_state_attributes["app_nickname"] = data["appNickname"] @@ -424,7 +455,6 @@ def __init__( self._attr_unique_id = ( f"{self._tibber_home.home_id}_{self.entity_description.key}" ) - self._attr_name = f"{entity_description.name} {self._home_name}" if entity_description.key == "month_cost": self._attr_native_unit_of_measurement = self._tibber_home.currency @@ -452,9 +482,8 @@ def __init__( self._model = "Tibber Pulse" self._device_name = f"{self._model} {self._home_name}" - self._attr_name = f"{description.name} {self._home_name}" self._attr_native_value = initial_state - self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" + self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.key}" if description.key in ("accumulatedCost", "accumulatedReward"): self._attr_native_unit_of_measurement = tibber_home.currency @@ -523,6 +552,7 @@ def __init__( self._async_remove_device_updates_handler = self.async_add_listener( self._add_sensors ) + self.entity_registry = async_get_entity_reg(hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback @@ -530,6 +560,49 @@ def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" self._async_remove_device_updates_handler() + @callback + def _migrate_unique_id(self, sensor_description: SensorEntityDescription) -> None: + """Migrate unique id if needed.""" + home_id = self._tibber_home.home_id + translation_key = sensor_description.translation_key + description_key = sensor_description.key + entity_id: str | None = None + if translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION_SIMPLE: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{translation_key.replace('_', ' ')}", + ) + elif translation_key in RT_SENSORS_UNIQUE_ID_MIGRATION: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{RT_SENSORS_UNIQUE_ID_MIGRATION[translation_key]}", + ) + elif translation_key != description_key: + entity_id = self.entity_registry.async_get_entity_id( + "sensor", + TIBBER_DOMAIN, + f"{home_id}_rt_{translation_key}", + ) + + if entity_id is None: + return + + new_unique_id = f"{home_id}_rt_{description_key}" + + _LOGGER.debug( + "Migrating unique id for %s to %s", + entity_id, + new_unique_id, + ) + try: + self.entity_registry.async_update_entity( + entity_id, new_unique_id=new_unique_id + ) + except ValueError as err: + _LOGGER.error(err) + @callback def _add_sensors(self) -> None: """Add sensor.""" @@ -543,6 +616,8 @@ def _add_sensors(self) -> None: state = live_measurement.get(sensor_description.key) if state is None: continue + + self._migrate_unique_id(sensor_description) entity = TibberSensorRT( self._tibber_home, sensor_description, diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index c7cef9f4657f39..af14c96674de2f 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -1,4 +1,89 @@ { + "entity": { + "sensor": { + "electricity_price": { + "name": "Electricity price" + }, + "month_cost": { + "name": "Monthly cost" + }, + "peak_hour": { + "name": "Monthly peak hour consumption" + }, + "peak_hour_time": { + "name": "Time of max hour consumption" + }, + "month_cons": { + "name": "Monthly net consumption" + }, + "average_power": { + "name": "Average power" + }, + "power": { + "name": "Power" + }, + "power_production": { + "name": "Power production" + }, + "min_power": { + "name": "Min power" + }, + "max_power": { + "name": "Max power" + }, + "accumulated_consumption": { + "name": "Accumulated consumption" + }, + "accumulated_consumption_last_hour": { + "name": "Accumulated consumption current hour" + }, + "estimated_hour_consumption": { + "name": "Estimated consumption current hour" + }, + "accumulated_production": { + "name": "Accumulated production" + }, + "accumulated_production_last_hour": { + "name": "Accumulated production current hour" + }, + "last_meter_consumption": { + "name": "Last meter consumption" + }, + "last_meter_production": { + "name": "Last meter production" + }, + "voltage_phase1": { + "name": "Voltage phase1" + }, + "voltage_phase2": { + "name": "Voltage phase2" + }, + "voltage_phase3": { + "name": "Voltage phase3" + }, + "current_l1": { + "name": "Current L1" + }, + "current_l2": { + "name": "Current L2" + }, + "current_l3": { + "name": "Current L3" + }, + "signal_strength": { + "name": "Signal strength" + }, + "accumulated_reward": { + "name": "Accumulated reward" + }, + "accumulated_cost": { + "name": "Accumulated cost" + }, + "power_factor": { + "name": "Power factor" + } + } + }, "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index d6855f42c0a2ef..aece537c8678f3 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -56,13 +56,12 @@ def _get_config_schema( vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, } - default_location = ( - input_dict[CONF_LOCATION] - if CONF_LOCATION in input_dict - else { + default_location = input_dict.get( + CONF_LOCATION, + { CONF_LATITUDE: hass.config.latitude, CONF_LONGITUDE: hass.config.longitude, - } + }, ) return vol.Schema( { diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index e2342e617de3d6..b8510f7ef8184f 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -28,7 +28,6 @@ CONF_MODEL, CONF_PASSWORD, CONF_USERNAME, - EVENT_HOMEASSISTANT_STARTED, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -112,14 +111,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the TP-Link component.""" hass.data.setdefault(DOMAIN, {}) - if discovered_devices := await async_discover_devices(hass): - async_trigger_discovery(hass, discovered_devices) - async def _async_discovery(*_: Any) -> None: if discovered := await async_discover_devices(hass): async_trigger_discovery(hass, discovered) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_discovery) + hass.async_create_background_task( + _async_discovery(), "tplink first discovery", eager_start=True + ) async_track_time_interval( hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True ) diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 10c0c16ff7f637..643748f175e181 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -28,7 +28,7 @@ CONF_USERNAME, ) from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import device_registry as dr from homeassistant.helpers.typing import DiscoveryInfoType @@ -77,25 +77,22 @@ async def async_step_integration_discovery( @callback def _update_config_if_entry_in_setup_error( self, entry: ConfigEntry, host: str, config: dict - ) -> None: + ) -> FlowResult | None: """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config.""" if entry.state not in ( ConfigEntryState.SETUP_ERROR, ConfigEntryState.SETUP_RETRY, ): - return + return None entry_data = entry.data entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) if entry_config_dict == config and entry_data[CONF_HOST] == host: - return - self.hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + return None + return self.async_update_reload_and_abort( + entry, + data={**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host}, + reason="already_configured", ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id), - f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", - ) - raise AbortFlow("already_configured") async def _async_handle_discovery( self, host: str, formatted_mac: str, config: dict | None = None @@ -104,8 +101,16 @@ async def _async_handle_discovery( current_entry = await self.async_set_unique_id( formatted_mac, raise_on_progress=False ) - if config and current_entry: - self._update_config_if_entry_in_setup_error(current_entry, host, config) + if ( + config + and current_entry + and ( + result := self._update_config_if_entry_in_setup_error( + current_entry, host, config + ) + ) + ): + return result self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) self.context[CONF_HOST] = host @@ -143,6 +148,8 @@ async def async_step_discovery_auth_confirm( self._discovered_device = device return await self.async_step_discovery_confirm() + placeholders = self._async_make_placeholders_from_discovery() + if user_input: username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] @@ -151,17 +158,18 @@ async def async_step_discovery_auth_confirm( device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException: + except AuthenticationException as ex: errors[CONF_PASSWORD] = "invalid_auth" - except SmartDeviceException: + placeholders["error"] = str(ex) + except SmartDeviceException as ex: errors["base"] = "cannot_connect" + placeholders["error"] = str(ex) else: self._discovered_device = device await set_credentials(self.hass, username, password) self.hass.async_create_task(self._async_reload_requires_auth_entries()) return self._async_create_entry_from_device(self._discovered_device) - placeholders = self._async_make_placeholders_from_discovery() self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="discovery_auth_confirm", @@ -199,7 +207,9 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} + placeholders: dict[str, str] = {} + if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() @@ -212,8 +222,9 @@ async def async_step_user( ) except AuthenticationException: return await self.async_step_user_auth_confirm() - except SmartDeviceException: + except SmartDeviceException as ex: errors["base"] = "cannot_connect" + placeholders["error"] = str(ex) else: return self._async_create_entry_from_device(device) @@ -221,14 +232,17 @@ async def async_step_user( step_id="user", data_schema=vol.Schema({vol.Optional(CONF_HOST, default=""): str}), errors=errors, + description_placeholders=placeholders, ) async def async_step_user_auth_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Dialog that informs the user that auth is required.""" - errors = {} + errors: dict[str, str] = {} host = self.context[CONF_HOST] + placeholders: dict[str, str] = {CONF_HOST: host} + assert self._discovered_device is not None if user_input: username = user_input[CONF_USERNAME] @@ -238,10 +252,12 @@ async def async_step_user_auth_confirm( device = await self._async_try_connect( self._discovered_device, credentials ) - except AuthenticationException: + except AuthenticationException as ex: errors[CONF_PASSWORD] = "invalid_auth" - except SmartDeviceException: + placeholders["error"] = str(ex) + except SmartDeviceException as ex: errors["base"] = "cannot_connect" + placeholders["error"] = str(ex) else: await set_credentials(self.hass, username, password) self.hass.async_create_task(self._async_reload_requires_auth_entries()) @@ -251,7 +267,7 @@ async def async_step_user_auth_confirm( step_id="user_auth_confirm", data_schema=STEP_AUTH_DATA_SCHEMA, errors=errors, - description_placeholders={CONF_HOST: host}, + description_placeholders=placeholders, ) async def async_step_pick_device( @@ -397,6 +413,7 @@ async def async_step_reauth_confirm( ) -> FlowResult: """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} + placeholders: dict[str, str] = {} reauth_entry = self.reauth_entry assert reauth_entry is not None entry_data = reauth_entry.data @@ -412,10 +429,12 @@ async def async_step_reauth_confirm( credentials=credentials, raise_on_progress=True, ) - except AuthenticationException: + except AuthenticationException as ex: errors[CONF_PASSWORD] = "invalid_auth" - except SmartDeviceException: + placeholders["error"] = str(ex) + except SmartDeviceException as ex: errors["base"] = "cannot_connect" + placeholders["error"] = str(ex) else: await set_credentials(self.hass, username, password) self.hass.async_create_task(self._async_reload_requires_auth_entries()) @@ -425,7 +444,8 @@ async def async_step_reauth_confirm( alias = entry_data.get(CONF_ALIAS) or "unknown" model = entry_data.get(CONF_MODEL) or "unknown" - placeholders = {"name": alias, "model": model, "host": host} + placeholders.update({"name": alias, "model": model, "host": host}) + self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="reauth_confirm", diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 87d30e4f76a019..e27ee7de49fd80 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -163,6 +163,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION _attr_name = None + _fixed_color_mode: ColorMode | None = None device: SmartBulb @@ -193,6 +194,9 @@ def __init__( if device.is_dimmable: modes.add(ColorMode.BRIGHTNESS) self._attr_supported_color_modes = filter_supported_color_modes(modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._fixed_color_mode = next(iter(self._attr_supported_color_modes)) self._async_update_attrs() @callback @@ -273,14 +277,14 @@ async def async_turn_off(self, **kwargs: Any) -> None: def _determine_color_mode(self) -> ColorMode: """Return the active color mode.""" - if self.device.is_color: - if self.device.is_variable_color_temp and self.device.color_temp: - return ColorMode.COLOR_TEMP - return ColorMode.HS - if self.device.is_variable_color_temp: - return ColorMode.COLOR_TEMP + if self._fixed_color_mode: + # The light supports only a single color mode, return it + return self._fixed_color_mode - return ColorMode.BRIGHTNESS + # The light supports both color temp and color, determine which on is active + if self.device.is_variable_color_temp and self.device.color_temp: + return ColorMode.COLOR_TEMP + return ColorMode.HS @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a91e7e5a46f9c9..f0a4696fd0beea 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -266,6 +266,7 @@ } ], "documentation": "https://www.home-assistant.io/integrations/tplink", + "import_executor": true, "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 4aa4a3856bddcc..19aa35f3604e6e 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -21,7 +21,7 @@ }, "user_auth_confirm": { "title": "Authenticate", - "description": "The device requires authentication, please input your credentials below.", + "description": "The device requires authentication, please input your TP-Link credentials below.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -29,7 +29,7 @@ }, "discovery_auth_confirm": { "title": "Authenticate", - "description": "The device requires authentication, please input your credentials below.", + "description": "[%key:component::tplink::config::step::user_auth_confirm::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -37,11 +37,11 @@ }, "reauth": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The device needs updated credentials, please input your credentials below." + "description": "[%key:component::tplink::config::step::user_auth_confirm::description%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The device needs updated credentials, please input your credentials below.", + "description": "[%key:component::tplink::config::step::user_auth_confirm::description%]", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" @@ -49,7 +49,8 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "Connection error: {error}", + "invalid_auth": "Invalid authentication: {error}" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 1367f8757af6bb..265b31bce9cbc4 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -1,13 +1,13 @@ """The TP-Link Omada integration.""" from __future__ import annotations +from tplink_omada_client import OmadaSite from tplink_omada_client.exceptions import ( ConnectionFailed, LoginFailed, OmadaClientException, UnsupportedControllerVersion, ) -from tplink_omada_client.omadaclient import OmadaSite from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -18,7 +18,7 @@ from .const import DOMAIN from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE, Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH, Platform.UPDATE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index caaae3465b77e1..d2679b8b8d4c8c 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -5,7 +5,11 @@ from attr import dataclass from tplink_omada_client.definitions import GatewayPortMode, LinkStatus -from tplink_omada_client.devices import OmadaDevice, OmadaGateway, OmadaGatewayPort +from tplink_omada_client.devices import ( + OmadaDevice, + OmadaGateway, + OmadaGatewayPortStatus, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -81,7 +85,7 @@ class GatewayPortBinarySensorConfig: id_suffix: str name_suffix: str device_class: BinarySensorDeviceClass - update_func: Callable[[OmadaGatewayPort], bool] + update_func: Callable[[OmadaGatewayPortStatus], bool] class OmadaGatewayPortBinarySensor(OmadaDeviceEntity[OmadaGateway], BinarySensorEntity): diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index 3f27417894dc57..e49e8ccf657c06 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -9,13 +9,13 @@ from urllib.parse import urlsplit from aiohttp import CookieJar +from tplink_omada_client import OmadaClient, OmadaSite from tplink_omada_client.exceptions import ( ConnectionFailed, LoginFailed, OmadaClientException, UnsupportedControllerVersion, ) -from tplink_omada_client.omadaclient import OmadaClient, OmadaSite import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index be9e875037e756..c9842f93a5a697 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,11 +1,11 @@ """Controller for sharing Omada API coordinators between platforms.""" +from tplink_omada_client import OmadaSiteClient from tplink_omada_client.devices import ( OmadaGateway, OmadaSwitch, OmadaSwitchPortDetails, ) -from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index e9048a678ca193..a0f3e6ff9b3f2e 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -4,8 +4,8 @@ import logging from typing import Generic, TypeVar +from tplink_omada_client import OmadaSiteClient from tplink_omada_client.exceptions import OmadaClientException -from tplink_omada_client.omadaclient import OmadaSiteClient from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 3215a9ba77dc57..33fc85d7c796fc 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.3.2"] + "requirements": ["tplink-omada-client==1.3.11"] } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 830f75b6a936ca..f8a124b94fc669 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -3,9 +3,9 @@ from typing import Any +from tplink_omada_client import SwitchPortOverrides from tplink_omada_client.definitions import PoEMode from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails -from tplink_omada_client.omadasiteclient import SwitchPortOverrides from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index a5f54071c4f101..014302cec6524a 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -4,9 +4,9 @@ from datetime import timedelta from typing import Any, NamedTuple +from tplink_omada_client import OmadaSiteClient 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 ( UpdateDeviceClass, diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json index c3b9e540ab6ef9..28e37a0e9cc80b 100644 --- a/homeassistant/components/traccar/manifest.json +++ b/homeassistant/components/traccar/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/traccar", "iot_class": "cloud_push", "loggers": ["pytraccar"], - "requirements": ["pytraccar==2.0.0", "stringcase==1.2.0"] + "requirements": ["pytraccar==2.1.1", "stringcase==1.2.0"] } diff --git a/homeassistant/components/traccar_server/__init__.py b/homeassistant/components/traccar_server/__init__.py index 53770757c8189f..dac54f5e3f8e79 100644 --- a/homeassistant/components/traccar_server/__init__.py +++ b/homeassistant/components/traccar_server/__init__.py @@ -1,6 +1,9 @@ """The Traccar Server integration.""" from __future__ import annotations +from datetime import timedelta + +from aiohttp import CookieJar from pytraccar import ApiClient from homeassistant.config_entries import ConfigEntry @@ -14,7 +17,8 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_CUSTOM_ATTRIBUTES, @@ -30,10 +34,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Traccar Server from a config entry.""" + client_session = async_create_clientsession( + hass, + cookie_jar=CookieJar( + unsafe=not entry.data[CONF_SSL] or not entry.data[CONF_VERIFY_SSL] + ), + ) coordinator = TraccarServerCoordinator( hass=hass, client=ApiClient( - client_session=async_get_clientsession(hass), + client_session=client_session, host=entry.data[CONF_HOST], port=entry.data[CONF_PORT], username=entry.data[CONF_USERNAME], @@ -54,6 +64,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + if entry.options.get(CONF_EVENTS): + entry.async_on_unload( + async_track_time_interval( + hass, + coordinator.import_events, + timedelta(seconds=30), + cancel_on_shutdown=True, + name="traccar_server_import_events", + ) + ) + + entry.async_create_background_task( + hass=hass, + target=coordinator.subscribe(), + name="Traccar Server subscription", + ) return True diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index df9b5adaf1a53f..960fdc01fa0eea 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import datetime, timedelta +from datetime import datetime from typing import TYPE_CHECKING, Any, TypedDict from pytraccar import ( @@ -10,11 +10,13 @@ DeviceModel, GeofenceModel, PositionModel, + SubscriptionData, TraccarException, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -31,7 +33,7 @@ class TraccarServerCoordinatorDataDevice(TypedDict): attributes: dict[str, Any] -TraccarServerCoordinatorData = dict[str, TraccarServerCoordinatorDataDevice] +TraccarServerCoordinatorData = dict[int, TraccarServerCoordinatorDataDevice] class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]): @@ -54,14 +56,16 @@ def __init__( hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=None, ) self.client = client self.custom_attributes = custom_attributes self.events = events self.max_accuracy = max_accuracy self.skip_accuracy_filter_for = skip_accuracy_filter_for + self._geofences: list[GeofenceModel] = [] self._last_event_import: datetime | None = None + self._should_log_subscription_error: bool = True async def _async_update_data(self) -> TraccarServerCoordinatorData: """Fetch data from Traccar Server.""" @@ -85,35 +89,21 @@ async def _async_update_data(self) -> TraccarServerCoordinatorData: assert isinstance(positions, list[PositionModel]) # type: ignore[misc] assert isinstance(geofences, list[GeofenceModel]) # type: ignore[misc] + self._geofences = geofences + for position in positions: if (device := get_device(position["deviceId"], devices)) is None: continue - attr = {} - skip_accuracy_filter = False - - for custom_attr in self.custom_attributes: - attr[custom_attr] = device["attributes"].get( - custom_attr, - position["attributes"].get(custom_attr, None), - ) - if custom_attr in self.skip_accuracy_filter_for: - skip_accuracy_filter = True - - accuracy = position["accuracy"] or 0.0 if ( - not skip_accuracy_filter - and self.max_accuracy > 0 - and accuracy > self.max_accuracy - ): - LOGGER.debug( - "Excluded position by accuracy filter: %f (%s)", - accuracy, - device["id"], + attr + := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( + device, position ) + ) is None: continue - data[device["uniqueId"]] = { + data[device["id"]] = { "device": device, "geofence": get_first_geofence( geofences, @@ -123,12 +113,55 @@ async def _async_update_data(self) -> TraccarServerCoordinatorData: "attributes": attr, } - if self.events: - self.hass.async_create_task(self.import_events(devices)) - return data - async def import_events(self, devices: list[DeviceModel]) -> None: + async def handle_subscription_data(self, data: SubscriptionData) -> None: + """Handle subscription data.""" + self.logger.debug("Received subscription data: %s", data) + self._should_log_subscription_error = True + update_devices = set() + for device in data.get("devices") or []: + device_id = device["id"] + if device_id not in self.data: + continue + + if ( + attr + := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( + device, self.data[device_id]["position"] + ) + ) is None: + continue + + self.data[device_id]["device"] = device + self.data[device_id]["attributes"] = attr + update_devices.add(device_id) + + for position in data.get("positions") or []: + device_id = position["deviceId"] + if device_id not in self.data: + continue + + if ( + attr + := self._return_custom_attributes_if_not_filtered_by_accuracy_configuration( + self.data[device_id]["device"], position + ) + ) is None: + continue + + self.data[device_id]["position"] = position + self.data[device_id]["attributes"] = attr + self.data[device_id]["geofence"] = get_first_geofence( + self._geofences, + position["geofenceIds"] or [], + ) + update_devices.add(device_id) + + for device_id in update_devices: + async_dispatcher_send(self.hass, f"{DOMAIN}_{device_id}") + + async def import_events(self, _: datetime) -> None: """Import events from Traccar.""" start_time = dt_util.utcnow().replace(tzinfo=None) end_time = None @@ -137,7 +170,7 @@ async def import_events(self, devices: list[DeviceModel]) -> None: end_time = start_time - (start_time - self._last_event_import) events = await self.client.get_reports_events( - devices=[device["id"] for device in devices], + devices=list(self.data), start_time=start_time, end_time=end_time, event_types=self.events, @@ -147,7 +180,7 @@ async def import_events(self, devices: list[DeviceModel]) -> None: self._last_event_import = start_time for event in events: - device = get_device(event["deviceId"], devices) + device = self.data[event["deviceId"]]["device"] self.hass.bus.async_fire( # This goes against two of the HA core guidelines: # 1. Event names should be prefixed with the domain name of @@ -165,3 +198,41 @@ async def import_events(self, devices: list[DeviceModel]) -> None: "attributes": event["attributes"], }, ) + + async def subscribe(self) -> None: + """Subscribe to events.""" + try: + await self.client.subscribe(self.handle_subscription_data) + except TraccarException as ex: + if self._should_log_subscription_error: + self._should_log_subscription_error = False + LOGGER.error("Error while subscribing to Traccar: %s", ex) + # Retry after 10 seconds + await asyncio.sleep(10) + await self.subscribe() + + def _return_custom_attributes_if_not_filtered_by_accuracy_configuration( + self, + device: DeviceModel, + position: PositionModel, + ) -> dict[str, Any] | None: + """Return a dictionary of custom attributes if not filtered by accuracy configuration.""" + attr = {} + skip_accuracy_filter = False + + for custom_attr in self.custom_attributes: + if custom_attr in self.skip_accuracy_filter_for: + skip_accuracy_filter = True + attr[custom_attr] = device["attributes"].get( + custom_attr, + position["attributes"].get(custom_attr, None), + ) + + accuracy = position["accuracy"] or 0.0 + if ( + not skip_accuracy_filter + and self.max_accuracy > 0 + and accuracy > self.max_accuracy + ): + return None + return attr diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py new file mode 100644 index 00000000000000..15b94a2b880b42 --- /dev/null +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -0,0 +1,81 @@ +"""Diagnostics platform for Traccar Server.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN +from .coordinator import TraccarServerCoordinator + +TO_REDACT = {CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entity_registry = er.async_get(hass) + + entities = er.async_entries_for_config_entry( + entity_registry, + config_entry_id=config_entry.entry_id, + ) + + return async_redact_data( + { + "subscription_status": coordinator.client.subscription_status, + "config_entry_options": dict(config_entry.options), + "coordinator_data": coordinator.data, + "entities": [ + { + "enity_id": entity.entity_id, + "disabled": entity.disabled, + "state": {"state": state.state, "attributes": state.attributes}, + } + for entity in entities + if (state := hass.states.get(entity.entity_id)) is not None + ], + }, + TO_REDACT, + ) + + +async def async_get_device_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, + device: dr.DeviceEntry, +) -> dict[str, Any]: + """Return device diagnostics.""" + coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id] + entity_registry = er.async_get(hass) + + entities = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + return async_redact_data( + { + "subscription_status": coordinator.client.subscription_status, + "config_entry_options": dict(entry.options), + "coordinator_data": coordinator.data, + "entities": [ + { + "enity_id": entity.entity_id, + "disabled": entity.disabled, + "state": {"state": state.state, "attributes": state.attributes}, + } + for entity in entities + if (state := hass.states.get(entity.entity_id)) is not None + ], + }, + TO_REDACT, + ) diff --git a/homeassistant/components/traccar_server/entity.py b/homeassistant/components/traccar_server/entity.py index d44c78cafae4a4..1c32008d09b5b6 100644 --- a/homeassistant/components/traccar_server/entity.py +++ b/homeassistant/components/traccar_server/entity.py @@ -6,6 +6,7 @@ from pytraccar import DeviceModel, GeofenceModel, PositionModel from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -22,7 +23,7 @@ def __init__( ) -> None: """Initialize the Traccar Server entity.""" super().__init__(coordinator) - self.device_id = device["uniqueId"] + self.device_id = device["id"] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device["uniqueId"])}, model=device["model"], @@ -33,10 +34,7 @@ def __init__( @property def available(self) -> bool: """Return True if entity is available.""" - return ( - self.coordinator.last_update_success - and self.device_id in self.coordinator.data - ) + return bool(self.coordinator.data and self.device_id in self.coordinator.data) @property def traccar_device(self) -> DeviceModel: @@ -57,3 +55,14 @@ def traccar_position(self) -> PositionModel: def traccar_attributes(self) -> dict[str, Any]: """Return the attributes.""" return self.coordinator.data[self.device_id]["attributes"] + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.device_id}", + self.async_write_ha_state, + ) + ) + await super().async_added_to_hass() diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index ca284dd02dd7fc..5fac2f108f7d90 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -4,6 +4,6 @@ "codeowners": ["@ludeeus"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", - "iot_class": "local_polling", - "requirements": ["pytraccar==2.0.0"] + "iot_class": "local_push", + "requirements": ["pytraccar==2.1.1"] } diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 00296f3108c04b..c115a549fd4db7 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -36,7 +36,6 @@ async def async_setup_entry( class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" - _attr_icon = "mdi:paw" _attr_translation_key = "tracker" def __init__(self, client: TractiveClient, item: Trackables) -> None: diff --git a/homeassistant/components/tractive/icons.json b/homeassistant/components/tractive/icons.json new file mode 100644 index 00000000000000..4fc4238d381d90 --- /dev/null +++ b/homeassistant/components/tractive/icons.json @@ -0,0 +1,58 @@ +{ + "entity": { + "device_tracker": { + "tracker": { + "default": "mdi:paw" + } + }, + "sensor": { + "activity": { + "default": "mdi:run" + }, + "activity_time": { + "default": "mdi:clock-time-eight-outline" + }, + "calories": { + "default": "mdi:fire" + }, + "daily_goal": { + "default": "mdi:flag-checkered" + }, + "minutes_day_sleep": { + "default": "mdi:sleep" + }, + "minutes_night_sleep": { + "default": "mdi:sleep" + }, + "rest_time": { + "default": "mdi:clock-time-eight-outline" + }, + "sleep": { + "default": "mdi:sleep" + }, + "tracker_state": { + "default": "mdi:radar" + } + }, + "switch": { + "tracker_buzzer": { + "default": "mdi:volume-high", + "state": { + "off": "mdi:volume-off" + } + }, + "tracker_led": { + "default": "mdi:led-on", + "state": { + "off": "mdi:led-off" + } + }, + "live_tracking": { + "default": "mdi:map-marker-path", + "state": { + "off": "mdi:map-marker-off" + } + } + } + } +} diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index ab9dad88e0689c..b563f536e210c2 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -111,7 +111,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: translation_key="tracker_state", signal_prefix=TRACKER_HARDWARE_STATUS_UPDATED, hardware_sensor=True, - icon="mdi:radar", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=[ @@ -124,7 +123,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, translation_key="activity_time", - icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -132,7 +130,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_MINUTES_REST, translation_key="rest_time", - icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -140,7 +137,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_CALORIES, translation_key="calories", - icon="mdi:fire", native_unit_of_measurement="kcal", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -148,14 +144,12 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_DAILY_GOAL, translation_key="daily_goal", - icon="mdi:flag-checkered", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_ACTIVITY_STATUS_UPDATED, ), TractiveSensorEntityDescription( key=ATTR_MINUTES_DAY_SLEEP, translation_key="minutes_day_sleep", - icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -163,7 +157,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_MINUTES_NIGHT_SLEEP, translation_key="minutes_night_sleep", - icon="mdi:sleep", native_unit_of_measurement=UnitOfTime.MINUTES, signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, state_class=SensorStateClass.TOTAL, @@ -171,7 +164,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_SLEEP_LABEL, translation_key="sleep", - icon="mdi:sleep", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, value_fn=lambda state: state.lower() if isinstance(state, str) else state, device_class=SensorDeviceClass.ENUM, @@ -184,7 +176,6 @@ def handle_status_update(self, event: dict[str, Any]) -> None: TractiveSensorEntityDescription( key=ATTR_ACTIVITY_LABEL, translation_key="activity", - icon="mdi:run", signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED, value_fn=lambda state: state.lower() if isinstance(state, str) else state, device_class=SensorDeviceClass.ENUM, diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index b77c35e6904ba4..4c838e5a468813 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -46,21 +46,18 @@ class TractiveSwitchEntityDescription( TractiveSwitchEntityDescription( key=ATTR_BUZZER, translation_key="tracker_buzzer", - icon="mdi:volume-high", method="async_set_buzzer", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LED, translation_key="tracker_led", - icon="mdi:led-on", method="async_set_led", entity_category=EntityCategory.CONFIG, ), TractiveSwitchEntityDescription( key=ATTR_LIVE_TRACKING, translation_key="live_tracking", - icon="mdi:map-marker-path", method="async_set_live_tracking", entity_category=EntityCategory.CONFIG, ), diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index a383cc2bbee0a2..9acdfb36a5d039 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -144,7 +144,7 @@ async def authenticate( key = await api_factory.generate_psk(security_code) except RequestError as err: raise AuthError("invalid_security_code") from err - except asyncio.TimeoutError as err: + except TimeoutError as err: raise AuthError("timeout") from err finally: await api_factory.shutdown() diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index f0f758272f764a..7303ba6836ba98 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -58,10 +58,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False if camera_id := camera_info.camera_id: - entry.version = 2 hass.config_entries.async_update_entry( entry, unique_id=f"{DOMAIN}-{camera_id}", + version=2, ) _LOGGER.debug( "Migrated Trafikverket Camera config entry unique id to %s", @@ -84,7 +84,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False if camera_id := camera_info.camera_id: - entry.version = 3 _LOGGER.debug( "Migrate Trafikverket Camera config entry unique id to %s", camera_id, @@ -92,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data = entry.data.copy() new_data.pop(CONF_LOCATION) new_data[CONF_ID] = camera_id - hass.config_entries.async_update_entry(entry, data=new_data) + hass.config_entries.async_update_entry(entry, data=new_data, version=3) return True _LOGGER.error("Could not migrate the config entry. Camera has no id") return False diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 32c97b4fe0a51c..4c209a3ba87c41 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -77,8 +77,7 @@ async def _async_update_data(self) -> dict[str, Any]: if self._time else dt_util.now() ) - if current_time > when: - when = current_time + when = max(when, current_time) try: routedata: list[ diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 5a6874fb35277e..a1ce3a60efebb1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -63,7 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Get all devices from Tuya try: await hass.async_add_executor_job(manager.update_device_cache) - except Exception as exc: # pylint: disable=broad-except + except Exception as exc: # While in general, we should avoid catching broad exceptions, # we have no other way of detecting this case. if "sign invalid" in str(exc): diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 9ebfe8995188b9..14ae9c4c426e18 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -92,13 +92,15 @@ def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): - self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - elif ( - enum_type := self.find_dpcode( - DPCode.MODE, dptype=DPType.ENUM, prefer_function=True + if ( + self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) + or ( + enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True + ) ) - ) and TUYA_MODE_RETURN_HOME in enum_type.range: + and TUYA_MODE_RETURN_HOME in enum_type.range + ): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME if self.find_dpcode(DPCode.SEEK, prefer_function=True): diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index d57a56f489b92e..3b47a10d4995e8 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -1,6 +1,5 @@ """The twinkly component.""" -import asyncio from aiohttp import ClientError from ttls.client import Twinkly @@ -31,7 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device_info = await client.get_details() software_version = await client.get_firmware_version() - except (asyncio.TimeoutError, ClientError) as exception: + except (TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception hass.data[DOMAIN][entry.entry_id] = { diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index e37e0fd6170202..6d0785f648ecf5 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -1,7 +1,6 @@ """Config flow to configure the Twinkly integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -40,7 +39,7 @@ async def async_step_user(self, user_input=None): device_info = await Twinkly( host, async_get_clientsession(self.hass) ).get_details() - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): errors[CONF_HOST] = "cannot_connect" else: await self.async_set_unique_id(device_info[DEV_ID]) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index c43019360886c6..453ba9007067fd 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -1,7 +1,6 @@ """The Twinkly light component.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -65,6 +64,8 @@ async def async_setup_entry( class TwinklyLight(LightEntity): """Implementation of the light for the Twinkly service.""" + _attr_has_entity_name = True + _attr_name = None _attr_icon = "mdi:string-lights" def __init__( @@ -93,7 +94,7 @@ def __init__( # Those are saved in the config entry in order to have meaningful values even # if the device is currently offline. # They are expected to be updated using the device_info. - self._name = conf.data[CONF_NAME] + self._name = conf.data[CONF_NAME] or "Twinkly light" self._model = conf.data[CONF_MODEL] self._client = client @@ -107,11 +108,6 @@ def __init__( # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT - @property - def name(self) -> str: - """Name of the device.""" - return self._name if self._name else "Twinkly light" - @property def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" @@ -119,7 +115,7 @@ def device_info(self) -> DeviceInfo | None: identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="LEDWORKS", model=self._model, - name=self.name, + name=self._name, sw_version=self._software_version, ) @@ -272,6 +268,15 @@ async def async_update(self) -> None: }, ) + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + {(DOMAIN, self._attr_unique_id)} + ) + if device_entry: + device_registry.async_update_device( + device_entry.id, name=self._name, model=self._model + ) + if LightEntityFeature.EFFECT & self.supported_features: await self.async_update_movies() await self.async_update_current_movie() @@ -282,7 +287,7 @@ async def async_update(self) -> None: # We don't use the echo API to track the availability since # we already have to pull the device to get its state. self._attr_available = True - except (asyncio.TimeoutError, ClientError): + except (TimeoutError, ClientError): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July if self._attr_available: diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index c6ab0bab893ad5..6ec89261b3d58e 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -6,6 +6,9 @@ "dhcp": [ { "hostname": "twinkly_*" + }, + { + "hostname": "twinkly-*" } ], "documentation": "https://www.home-assistant.io/integrations/twinkly", diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py index 76b6ec709ff179..a26b7e940351b0 100644 --- a/homeassistant/components/twitch/__init__.py +++ b/homeassistant/components/twitch/__init__.py @@ -1,14 +1,17 @@ """The Twitch component.""" from __future__ import annotations +from typing import cast + from aiohttp.client_exceptions import ClientError, ClientResponseError from twitchAPI.twitch import Twitch from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.config_entry_oauth2_flow import ( + LocalOAuth2Implementation, OAuth2Session, async_get_config_entry_implementation, ) @@ -18,7 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Twitch from a config entry.""" - implementation = await async_get_config_entry_implementation(hass, entry) + implementation = cast( + LocalOAuth2Implementation, + await async_get_config_entry_implementation(hass, entry), + ) session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() @@ -31,10 +37,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ClientError as err: raise ConfigEntryNotReady from err - app_id = implementation.__dict__[CONF_CLIENT_ID] access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] client = await Twitch( - app_id=app_id, + app_id=implementation.client_id, authenticate_app=False, ) client.auto_refresh_auth = False diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index 9e586b19a5a34b..128abf756fa4fe 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Mapping import logging -from typing import Any +from typing import Any, cast from twitchAPI.helper import first from twitchAPI.twitch import Twitch @@ -14,6 +14,7 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_CHANNELS, CONF_REFRESH_TOKEN, DOMAIN, LOGGER, OAUTH_SCOPES @@ -47,9 +48,13 @@ async def async_oauth_create_entry( data: dict[str, Any], ) -> FlowResult: """Handle the initial step.""" + implementation = cast( + LocalOAuth2Implementation, + self.flow_impl, + ) client = await Twitch( - app_id=self.flow_impl.__dict__[CONF_CLIENT_ID], + app_id=implementation.client_id, authenticate_app=False, ) client.auto_refresh_auth = False diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index 4f1e1c5cf232f7..db17b55b2e95f3 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Ukraine Alarm.""" from __future__ import annotations -import asyncio import logging import aiohttp @@ -50,7 +49,7 @@ async def async_step_user(self, user_input=None): except aiohttp.ClientError as ex: reason = "unknown" unknown_err_msg = str(ex) - except asyncio.TimeoutError: + except TimeoutError: reason = "timeout" if not reason and not regions: diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index e435b68fc39980..dda91801084270 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,8 +11,8 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN as UNIFI_DOMAIN, PLATFORMS, UNIFI_WIRELESS_CLIENTS -from .controller import UniFiController, get_unifi_controller from .errors import AuthenticationRequired, CannotConnect +from .hub import UnifiHub, get_unifi_api from .services import async_setup_services, async_unload_services SAVE_DELAY = 10 @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(UNIFI_DOMAIN, {}) try: - api = await get_unifi_controller(hass, config_entry.data) + api = await get_unifi_api(hass, config_entry.data) except CannotConnect as err: raise ConfigEntryNotReady from err @@ -43,20 +43,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except AuthenticationRequired as err: raise ConfigEntryAuthFailed from err - controller = UniFiController(hass, config_entry, api) - await controller.initialize() - hass.data[UNIFI_DOMAIN][config_entry.entry_id] = controller + hub = UnifiHub(hass, config_entry, api) + await hub.initialize() + hass.data[UNIFI_DOMAIN][config_entry.entry_id] = hub await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - controller.async_update_device_registry() + hub.async_update_device_registry() if len(hass.data[UNIFI_DOMAIN]) == 1: async_setup_services(hass) - controller.start_websocket() + hub.websocket.start() config_entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, controller.shutdown) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) ) return True @@ -64,12 +64,12 @@ 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: UniFiController = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) + hub: UnifiHub = hass.data[UNIFI_DOMAIN].pop(config_entry.entry_id) if not hass.data[UNIFI_DOMAIN]: async_unload_services(hass) - return await controller.async_reset() + return await hub.async_reset() class UnifiWirelessClients: diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index c77a1f01447f9c..f03971267bbae6 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -30,7 +30,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .controller import UniFiController from .entity import ( HandlerT, UnifiEntity, @@ -38,6 +37,7 @@ async_device_available_fn, async_device_device_info_fn, ) +from .hub import UnifiHub @callback @@ -79,7 +79,7 @@ class UnifiButtonEntityDescription( entity_category=EntityCategory.CONFIG, has_entity_name=True, device_class=ButtonDeviceClass.RESTART, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, control_fn=async_restart_device_control_fn, @@ -89,15 +89,15 @@ class UnifiButtonEntityDescription( name_fn=lambda _: "Restart", object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"device_restart-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"device_restart-{obj_id}", ), UnifiButtonEntityDescription[Ports, Port]( key="PoE power cycle", entity_category=EntityCategory.CONFIG, has_entity_name=True, device_class=ButtonDeviceClass.RESTART, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, control_fn=async_power_cycle_port_control_fn, @@ -107,8 +107,8 @@ class UnifiButtonEntityDescription( name_fn=lambda port: f"{port.name} Power Cycle", object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: f"power_cycle-{obj_id}", + supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].port_poe, + unique_id_fn=lambda hub, obj_id: f"power_cycle-{obj_id}", ), ) @@ -119,7 +119,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, @@ -136,7 +136,7 @@ class UnifiButtonEntity(UnifiEntity[HandlerT, ApiItemT], ButtonEntity): async def async_press(self) -> None: """Press the button.""" - await self.entity_description.control_fn(self.controller.api, self._obj_id) + await self.entity_description.control_fn(self.hub.api, self._obj_id) @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index e1867b2df2e5d8..fabdc9849fa4a8 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -47,8 +47,8 @@ DEFAULT_DPI_RESTRICTIONS, DOMAIN as UNIFI_DOMAIN, ) -from .controller import UniFiController, get_unifi_controller from .errors import AuthenticationRequired, CannotConnect +from .hub import UnifiHub, get_unifi_api DEFAULT_PORT = 443 DEFAULT_SITE_ID = "default" @@ -99,11 +99,9 @@ async def async_step_user( } try: - controller = await get_unifi_controller( - self.hass, MappingProxyType(self.config) - ) - await controller.sites.update() - self.sites = controller.sites + hub = await get_unifi_api(self.hass, MappingProxyType(self.config)) + await hub.sites.update() + self.sites = hub.sites except AuthenticationRequired: errors["base"] = "faulty_credentials" @@ -160,18 +158,16 @@ async def async_step_site( abort_reason = "reauth_successful" if config_entry: - controller: UniFiController | None = self.hass.data.get( - UNIFI_DOMAIN, {} - ).get(config_entry.entry_id) + hub: UnifiHub | None = self.hass.data.get(UNIFI_DOMAIN, {}).get( + config_entry.entry_id + ) - if controller and controller.available: + if hub and hub.available: return self.async_abort(reason="already_configured") - self.hass.config_entries.async_update_entry( - config_entry, data=self.config + return self.async_update_reload_and_abort( + config_entry, data=self.config, reason=abort_reason ) - await self.hass.config_entries.async_reload(config_entry.entry_id) - return self.async_abort(reason=abort_reason) site_nice_name = self.sites[unique_id].description return self.async_create_entry(title=site_nice_name, data=self.config) @@ -242,7 +238,7 @@ async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowRes class UnifiOptionsFlowHandler(config_entries.OptionsFlow): """Handle Unifi Network options.""" - controller: UniFiController + hub: UnifiHub def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize UniFi Network options flow.""" @@ -255,8 +251,8 @@ async def async_step_init( """Manage the UniFi Network options.""" if self.config_entry.entry_id not in self.hass.data[UNIFI_DOMAIN]: return self.async_abort(reason="integration_not_setup") - self.controller = self.hass.data[UNIFI_DOMAIN][self.config_entry.entry_id] - self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients + self.hub = self.hass.data[UNIFI_DOMAIN][self.config_entry.entry_id] + self.options[CONF_BLOCK_CLIENT] = self.hub.option_block_clients if self.show_advanced_options: return await self.async_step_configure_entity_sources() @@ -273,7 +269,7 @@ async def async_step_simple_options( clients_to_block = {} - for client in self.controller.api.clients.values(): + for client in self.hub.api.clients.values(): clients_to_block[ client.mac ] = f"{client.name or client.hostname} ({client.mac})" @@ -284,11 +280,11 @@ async def async_step_simple_options( { vol.Optional( CONF_TRACK_CLIENTS, - default=self.controller.option_track_clients, + default=self.hub.option_track_clients, ): bool, vol.Optional( CONF_TRACK_DEVICES, - default=self.controller.option_track_devices, + default=self.hub.option_track_devices, ): bool, vol.Optional( CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] @@ -308,7 +304,7 @@ async def async_step_configure_entity_sources( clients = { client.mac: f"{client.name or client.hostname} ({client.mac})" - for client in self.controller.api.clients.values() + for client in self.hub.api.clients.values() } clients |= { mac: f"Unknown ({mac})" @@ -340,16 +336,16 @@ async def async_step_device_tracker( return await self.async_step_client_control() ssids = ( - {wlan.name for wlan in self.controller.api.wlans.values()} + {wlan.name for wlan in self.hub.api.wlans.values()} | { f"{wlan.name}{wlan.name_combine_suffix}" - for wlan in self.controller.api.wlans.values() + for wlan in self.hub.api.wlans.values() if not wlan.name_combine_enabled and wlan.name_combine_suffix is not None } | { wlan["name"] - for ap in self.controller.api.devices.values() + for ap in self.hub.api.devices.values() for wlan in ap.wlan_overrides if "name" in wlan } @@ -357,7 +353,7 @@ async def async_step_device_tracker( ssid_filter = {ssid: ssid for ssid in sorted(ssids)} selected_ssids_to_filter = [ - ssid for ssid in self.controller.option_ssid_filter if ssid in ssid_filter + ssid for ssid in self.hub.option_ssid_filter if ssid in ssid_filter ] return self.async_show_form( @@ -366,28 +362,26 @@ async def async_step_device_tracker( { vol.Optional( CONF_TRACK_CLIENTS, - default=self.controller.option_track_clients, + default=self.hub.option_track_clients, ): bool, vol.Optional( CONF_TRACK_WIRED_CLIENTS, - default=self.controller.option_track_wired_clients, + default=self.hub.option_track_wired_clients, ): bool, vol.Optional( CONF_TRACK_DEVICES, - default=self.controller.option_track_devices, + default=self.hub.option_track_devices, ): bool, vol.Optional( CONF_SSID_FILTER, default=selected_ssids_to_filter ): cv.multi_select(ssid_filter), vol.Optional( CONF_DETECTION_TIME, - default=int( - self.controller.option_detection_time.total_seconds() - ), + default=int(self.hub.option_detection_time.total_seconds()), ): int, vol.Optional( CONF_IGNORE_WIRED_BUG, - default=self.controller.option_ignore_wired_bug, + default=self.hub.option_ignore_wired_bug, ): bool, } ), @@ -404,7 +398,7 @@ async def async_step_client_control( clients_to_block = {} - for client in self.controller.api.clients.values(): + for client in self.hub.api.clients.values(): clients_to_block[ client.mac ] = f"{client.name or client.hostname} ({client.mac})" @@ -447,11 +441,11 @@ async def async_step_statistics_sensors( { vol.Optional( CONF_ALLOW_BANDWIDTH_SENSORS, - default=self.controller.option_allow_bandwidth_sensors, + default=self.hub.option_allow_bandwidth_sensors, ): bool, vol.Optional( CONF_ALLOW_UPTIME_SENSORS, - default=self.controller.option_allow_uptime_sensors, + default=self.hub.option_allow_uptime_sensors, ): bool, } ), diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 88667d8e81160a..87bc0b6c59b6a2 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -25,13 +25,14 @@ import homeassistant.helpers.entity_registry as er import homeassistant.util.dt as dt_util -from .controller import UNIFI_DOMAIN, UniFiController +from .const import DOMAIN as UNIFI_DOMAIN from .entity import ( HandlerT, UnifiEntity, UnifiEntityDescription, async_device_available_fn, ) +from .hub import UnifiHub LOGGER = logging.getLogger(__name__) @@ -79,23 +80,23 @@ @callback -def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if client is allowed.""" - if obj_id in controller.option_supported_clients: + if obj_id in hub.option_supported_clients: return True - if not controller.option_track_clients: + if not hub.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: + client = hub.api.clients[obj_id] + if client.mac not in hub.wireless_clients: + if not hub.option_track_wired_clients: return False elif ( client.essid - and controller.option_ssid_filter - and client.essid not in controller.option_ssid_filter + and hub.option_ssid_filter + and client.essid not in hub.option_ssid_filter ): return False @@ -103,25 +104,25 @@ def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: @callback -def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_is_connected_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if device object is disabled.""" - client = controller.api.clients[obj_id] + client = hub.api.clients[obj_id] - if controller.wireless_clients.is_wireless(client) and client.is_wired: - if not controller.option_ignore_wired_bug: + if hub.wireless_clients.is_wireless(client) and client.is_wired: + if not hub.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 + and hub.option_ssid_filter + and client.essid not in hub.option_ssid_filter ): return False if ( dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) - > controller.option_detection_time + > hub.option_detection_time ): return False @@ -129,11 +130,9 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo @callback -def async_device_heartbeat_timedelta_fn( - controller: UniFiController, obj_id: str -) -> timedelta: +def async_device_heartbeat_timedelta_fn(hub: UnifiHub, obj_id: str) -> timedelta: """Check if device object is disabled.""" - device = controller.api.devices[obj_id] + device = hub.api.devices[obj_id] return timedelta(seconds=device.next_interval + 60) @@ -141,9 +140,9 @@ def async_device_heartbeat_timedelta_fn( class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" - heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] + heartbeat_timedelta_fn: Callable[[UnifiHub, str], timedelta] ip_address_fn: Callable[[aiounifi.Controller, str], str | None] - is_connected_fn: Callable[[UniFiController, str], bool] + is_connected_fn: Callable[[UnifiHub, str], bool] hostname_fn: Callable[[aiounifi.Controller, str], str | None] @@ -161,7 +160,7 @@ class UnifiTrackerEntityDescription( 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, + available_fn=lambda hub, obj_id: hub.available, device_info_fn=lambda api, obj_id: None, event_is_on=(WIRED_CONNECTION + WIRELESS_CONNECTION), event_to_subscribe=( @@ -170,20 +169,20 @@ class UnifiTrackerEntityDescription( + WIRELESS_CONNECTION + WIRELESS_DISCONNECTION ), - heartbeat_timedelta_fn=lambda controller, _: controller.option_detection_time, + heartbeat_timedelta_fn=lambda hub, _: hub.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], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"{controller.site}-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"{hub.site}-{obj_id}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, hostname_fn=lambda api, obj_id: api.clients[obj_id].hostname, ), UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, - allowed_fn=lambda controller, obj_id: controller.option_track_devices, + allowed_fn=lambda hub, obj_id: hub.option_track_devices, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=lambda api, obj_id: None, @@ -194,8 +193,8 @@ class UnifiTrackerEntityDescription( name_fn=lambda device: device.name or device.model, object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: obj_id, + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: obj_id, ip_address_fn=lambda api, obj_id: api.devices[obj_id].ip, hostname_fn=lambda api, obj_id: None, ), @@ -208,21 +207,21 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> No Introduced with release 2023.12. """ - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] ent_reg = er.async_get(hass) @callback def update_unique_id(obj_id: str) -> None: """Rework unique ID.""" - new_unique_id = f"{controller.site}-{obj_id}" + new_unique_id = f"{hub.site}-{obj_id}" if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): return - unique_id = f"{obj_id}-{controller.site}" + unique_id = f"{obj_id}-{hub.site}" if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) - for obj_id in list(controller.api.clients) + list(controller.api.clients_all): + for obj_id in list(hub.api.clients) + list(hub.api.clients_all): update_unique_id(obj_id) @@ -233,7 +232,7 @@ async def async_setup_entry( ) -> None: """Set up device tracker for UniFi Network integration.""" async_update_unique_id(hass, config_entry) - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) @@ -256,12 +255,12 @@ def async_initiate_state(self) -> None: 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) + self._is_connected = description.is_connected_fn(self.hub, self._obj_id) if self.is_connected: - self.controller.async_heartbeat( + self.hub.async_heartbeat( self.unique_id, dt_util.utcnow() - + description.heartbeat_timedelta_fn(self.controller, self._obj_id), + + description.heartbeat_timedelta_fn(self.hub, self._obj_id), ) @property @@ -272,12 +271,12 @@ def is_connected(self) -> bool: @property def hostname(self) -> str | None: """Return hostname of the device.""" - return self.entity_description.hostname_fn(self.controller.api, self._obj_id) + return self.entity_description.hostname_fn(self.hub.api, self._obj_id) @property def ip_address(self) -> str | None: """Return the primary ip address of the device.""" - return self.entity_description.ip_address_fn(self.controller.api, self._obj_id) + return self.entity_description.ip_address_fn(self.hub.api, self._obj_id) @property def mac_address(self) -> str: @@ -304,7 +303,7 @@ def _make_disconnected(self, *_: core_Event) -> None: def async_update_state(self, event: ItemEvent, obj_id: str) -> None: """Update entity state. - Remove heartbeat check if controller state has changed + Remove heartbeat check if hub connection state has changed and entity is unavailable. Update is_connected. Schedule new heartbeat check if connected. @@ -319,15 +318,15 @@ def async_update_state(self, event: ItemEvent, obj_id: str) -> None: # From unifi.entity.async_signal_reachable_callback # Controller connection state has changed and entity is unavailable # Cancel heartbeat - self.controller.async_heartbeat(self.unique_id) + self.hub.async_heartbeat(self.unique_id) return - if is_connected := description.is_connected_fn(self.controller, self._obj_id): + if is_connected := description.is_connected_fn(self.hub, self._obj_id): self._is_connected = is_connected - self.controller.async_heartbeat( + self.hub.async_heartbeat( self.unique_id, dt_util.utcnow() - + description.heartbeat_timedelta_fn(self.controller, self._obj_id), + + description.heartbeat_timedelta_fn(self.hub, self._obj_id), ) @callback @@ -337,17 +336,15 @@ def async_event_callback(self, event: Event) -> None: return if event.key in self._event_is_on: - self.controller.async_heartbeat(self.unique_id) + self.hub.async_heartbeat(self.unique_id) self._is_connected = True self.async_write_ha_state() return - self.controller.async_heartbeat( + self.hub.async_heartbeat( self.unique_id, dt_util.utcnow() - + self.entity_description.heartbeat_timedelta_fn( - self.controller, self._obj_id - ), + + self.entity_description.heartbeat_timedelta_fn(self.hub, self._obj_id), ) async def async_added_to_hass(self) -> None: @@ -356,7 +353,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", + f"{self.hub.signal_heartbeat_missed}_{self.unique_id}", self._make_disconnected, ) ) @@ -364,7 +361,7 @@ async def async_added_to_hass(self) -> None: async def async_will_remove_from_hass(self) -> None: """Disconnect object when removed.""" await super().async_will_remove_from_hass() - self.controller.async_heartbeat(self.unique_id) + self.hub.async_heartbeat(self.unique_id) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -372,7 +369,7 @@ def extra_state_attributes(self) -> Mapping[str, Any] | None: if self.entity_description.key != "Client device scanner": return None - client = self.entity_description.object_fn(self.controller.api, self._obj_id) + client = self.entity_description.object_fn(self.hub.api, self._obj_id) raw = client.raw attributes_to_check = CLIENT_STATIC_ATTRIBUTES diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py index c01dc19307880e..2482f5ca3140be 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN as UNIFI_DOMAIN -from .controller import UniFiController +from .hub import UnifiHub TO_REDACT = {CONF_PASSWORD} REDACT_CONFIG = {CONF_HOST, CONF_PASSWORD, CONF_USERNAME} @@ -75,16 +75,16 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] diag: dict[str, Any] = {} macs_to_redact: dict[str, str] = {} counter = 0 - for mac in chain(controller.api.clients, controller.api.devices): + for mac in chain(hub.api.clients, hub.api.devices): macs_to_redact[mac] = format_mac(str(counter).zfill(12)) counter += 1 - for device in controller.api.devices.values(): + for device in hub.api.devices.values(): for entry in device.raw.get("ethernet_table", []): mac = entry.get("mac", "") if mac not in macs_to_redact: @@ -94,26 +94,26 @@ async def async_get_config_entry_diagnostics( diag["config"] = async_redact_data( async_replace_dict_data(config_entry.as_dict(), macs_to_redact), REDACT_CONFIG ) - diag["role_is_admin"] = controller.is_admin + diag["role_is_admin"] = hub.is_admin diag["clients"] = { macs_to_redact[k]: async_redact_data( async_replace_dict_data(v.raw, macs_to_redact), REDACT_CLIENTS ) - for k, v in controller.api.clients.items() + for k, v in hub.api.clients.items() } diag["devices"] = { macs_to_redact[k]: async_redact_data( async_replace_dict_data(v.raw, macs_to_redact), REDACT_DEVICES ) - for k, v in controller.api.devices.items() + for k, v in hub.api.devices.items() } - diag["dpi_apps"] = {k: v.raw for k, v in controller.api.dpi_apps.items()} - diag["dpi_groups"] = {k: v.raw for k, v in controller.api.dpi_groups.items()} + diag["dpi_apps"] = {k: v.raw for k, v in hub.api.dpi_apps.items()} + diag["dpi_groups"] = {k: v.raw for k, v in hub.api.dpi_groups.items()} diag["wlans"] = { k: async_redact_data( async_replace_dict_data(v.raw, macs_to_redact), REDACT_WLANS ) - for k, v in controller.api.wlans.items() + for k, v in hub.api.wlans.items() } return diag diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 08dda12c11da42..a88f4c9b657683 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -29,36 +29,36 @@ from .const import ATTR_MANUFACTURER, DOMAIN if TYPE_CHECKING: - from .controller import UniFiController + from .hub import UnifiHub HandlerT = TypeVar("HandlerT", bound=APIHandler) SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] @callback -def async_device_available_fn(controller: UniFiController, obj_id: str) -> bool: +def async_device_available_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if device is available.""" if "_" in obj_id: # Sub device (outlet or port) obj_id = obj_id.partition("_")[0] - device = controller.api.devices[obj_id] - return controller.available and not device.disabled + device = hub.api.devices[obj_id] + return hub.available and not device.disabled @callback -def async_wlan_available_fn(controller: UniFiController, obj_id: str) -> bool: +def async_wlan_available_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if WLAN is available.""" - wlan = controller.api.wlans[obj_id] - return controller.available and wlan.enabled + wlan = hub.api.wlans[obj_id] + return hub.available and wlan.enabled @callback -def async_device_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: +def async_device_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for device.""" if "_" in obj_id: # Sub device (outlet or port) obj_id = obj_id.partition("_")[0] - device = controller.api.devices[obj_id] + device = hub.api.devices[obj_id] return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, device.mac)}, manufacturer=ATTR_MANUFACTURER, @@ -70,9 +70,9 @@ def async_device_device_info_fn(controller: UniFiController, obj_id: str) -> Dev @callback -def async_wlan_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: +def async_wlan_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for WLAN.""" - wlan = controller.api.wlans[obj_id] + wlan = hub.api.wlans[obj_id] return DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, wlan.id)}, @@ -83,9 +83,9 @@ def async_wlan_device_info_fn(controller: UniFiController, obj_id: str) -> Devic @callback -def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> DeviceInfo: +def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for client.""" - client = controller.api.clients[obj_id] + client = hub.api.clients[obj_id] return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, obj_id)}, default_manufacturer=client.oui, @@ -97,17 +97,17 @@ def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> Dev class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - allowed_fn: Callable[[UniFiController, str], bool] + allowed_fn: Callable[[UnifiHub, str], bool] api_handler_fn: Callable[[aiounifi.Controller], HandlerT] - available_fn: Callable[[UniFiController, str], bool] - device_info_fn: Callable[[UniFiController, str], DeviceInfo | None] + available_fn: Callable[[UnifiHub, str], bool] + device_info_fn: Callable[[UnifiHub, str], DeviceInfo | None] event_is_on: tuple[EventKey, ...] | None event_to_subscribe: tuple[EventKey, ...] | None name_fn: Callable[[ApiItemT], str | None] object_fn: Callable[[aiounifi.Controller, str], ApiItemT] should_poll: bool - supported_fn: Callable[[UniFiController, str], bool | None] - unique_id_fn: Callable[[UniFiController, str], str] + supported_fn: Callable[[UnifiHub, str], bool | None] + unique_id_fn: Callable[[UnifiHub, str], str] @dataclass(frozen=True) @@ -124,36 +124,36 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): def __init__( self, obj_id: str, - controller: UniFiController, + hub: UnifiHub, description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id - self.controller = controller + self.hub = hub self.entity_description = description - controller.known_objects.add((description.key, obj_id)) + hub.known_objects.add((description.key, obj_id)) self._removed = False - self._attr_available = description.available_fn(controller, obj_id) - self._attr_device_info = description.device_info_fn(controller, obj_id) + self._attr_available = description.available_fn(hub, obj_id) + self._attr_device_info = description.device_info_fn(hub, obj_id) self._attr_should_poll = description.should_poll - self._attr_unique_id = description.unique_id_fn(controller, obj_id) + self._attr_unique_id = description.unique_id_fn(hub, obj_id) - obj = description.object_fn(self.controller.api, obj_id) + obj = description.object_fn(self.hub.api, obj_id) self._attr_name = description.name_fn(obj) self.async_initiate_state() async def async_added_to_hass(self) -> None: """Register callbacks.""" description = self.entity_description - handler = description.api_handler_fn(self.controller.api) + handler = description.api_handler_fn(self.hub.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.hub.known_objects.discard((description.key, self._obj_id)) self.async_on_remove(unregister_object) @@ -165,11 +165,11 @@ def unregister_object() -> None: ) ) - # State change from controller or websocket + # State change from hub or websocket self.async_on_remove( async_dispatcher_connect( self.hass, - self.controller.signal_reachable, + self.hub.signal_reachable, self.async_signal_reachable_callback, ) ) @@ -178,7 +178,7 @@ def unregister_object() -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - self.controller.signal_options_update, + self.hub.signal_options_update, self.async_signal_options_updated, ) ) @@ -186,7 +186,7 @@ def unregister_object() -> None: # Subscribe to events if defined if description.event_to_subscribe is not None: self.async_on_remove( - self.controller.api.events.subscribe( + self.hub.api.events.subscribe( self.async_event_callback, description.event_to_subscribe, ) @@ -200,22 +200,22 @@ def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: return description = self.entity_description - if not description.supported_fn(self.controller, self._obj_id): + if not description.supported_fn(self.hub, self._obj_id): self.hass.async_create_task(self.remove_item({self._obj_id})) return - self._attr_available = description.available_fn(self.controller, self._obj_id) + self._attr_available = description.available_fn(self.hub, self._obj_id) self.async_update_state(event, obj_id) self.async_write_ha_state() @callback def async_signal_reachable_callback(self) -> None: - """Call when controller connection state change.""" + """Call when hub connection state change.""" self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) async def async_signal_options_updated(self) -> None: """Config entry options are updated, remove entity if option is disabled.""" - if not self.entity_description.allowed_fn(self.controller, self._obj_id): + if not self.entity_description.allowed_fn(self.hub, self._obj_id): await self.remove_item({self._obj_id}) async def remove_item(self, keys: set) -> None: diff --git a/homeassistant/components/unifi/errors.py b/homeassistant/components/unifi/errors.py index c3b2bb23d8e1df..568bd5fb842113 100644 --- a/homeassistant/components/unifi/errors.py +++ b/homeassistant/components/unifi/errors.py @@ -15,7 +15,7 @@ class AuthenticationRequired(UnifiException): class CannotConnect(UnifiException): - """Unable to connect to the controller.""" + """Unable to connect to UniFi Network.""" class LoginRequired(UnifiException): diff --git a/homeassistant/components/unifi/hub/__init__.py b/homeassistant/components/unifi/hub/__init__.py new file mode 100644 index 00000000000000..b8ed15d46f4859 --- /dev/null +++ b/homeassistant/components/unifi/hub/__init__.py @@ -0,0 +1,4 @@ +"""Internal functionality not part of HA infrastructure.""" + +from .api import get_unifi_api # noqa: F401 +from .hub import UnifiHub # noqa: F401 diff --git a/homeassistant/components/unifi/hub/api.py b/homeassistant/components/unifi/hub/api.py new file mode 100644 index 00000000000000..8a1be0427b27de --- /dev/null +++ b/homeassistant/components/unifi/hub/api.py @@ -0,0 +1,92 @@ +"""Provide an object to communicate with UniFi Network application.""" + +from __future__ import annotations + +import asyncio +import ssl +from types import MappingProxyType +from typing import Any, Literal + +from aiohttp import CookieJar +import aiounifi +from aiounifi.models.configuration import Configuration + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from ..const import CONF_SITE_ID, LOGGER +from ..errors import AuthenticationRequired, CannotConnect + + +async def get_unifi_api( + hass: HomeAssistant, + config: MappingProxyType[str, Any], +) -> aiounifi.Controller: + """Create a aiounifi object and verify authentication.""" + ssl_context: ssl.SSLContext | Literal[False] = False + + 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=False, cookie_jar=CookieJar(unsafe=True) + ) + + api = aiounifi.Controller( + Configuration( + session, + host=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], + site=config[CONF_SITE_ID], + ssl_context=ssl_context, + ) + ) + + try: + async with asyncio.timeout(10): + await api.login() + return api + + except aiounifi.Unauthorized as err: + LOGGER.warning( + "Connected to UniFi Network at %s but not registered: %s", + config[CONF_HOST], + err, + ) + raise AuthenticationRequired from err + + except ( + TimeoutError, + aiounifi.BadGateway, + aiounifi.Forbidden, + aiounifi.ServiceUnavailable, + aiounifi.RequestError, + aiounifi.ResponseError, + ) as err: + LOGGER.error( + "Error connecting to the UniFi Network at %s: %s", config[CONF_HOST], err + ) + raise CannotConnect from err + + except aiounifi.LoginRequired as err: + LOGGER.warning( + "Connected to UniFi Network at %s but login required: %s", + config[CONF_HOST], + err, + ) + raise AuthenticationRequired from err + + except aiounifi.AiounifiException as err: + LOGGER.exception("Unknown UniFi Network communication error occurred: %s", err) + raise AuthenticationRequired from err diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/hub/hub.py similarity index 62% rename from homeassistant/components/unifi/controller.py rename to homeassistant/components/unifi/hub/hub.py index eb127a5dfd91df..0188adf5c3fab4 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -1,34 +1,18 @@ """UniFi Network abstraction.""" from __future__ import annotations -import asyncio +from collections.abc import Iterable from datetime import datetime, timedelta -import ssl -from types import MappingProxyType -from typing import Any, Literal +from functools import partial -import aiohttp -from aiohttp import CookieJar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent -from aiounifi.models.configuration import Configuration from aiounifi.models.device import DeviceSetPoePortModeRequest from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, - Platform, -) +from homeassistant.const import CONF_HOST, Platform 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 import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceEntryType, @@ -43,7 +27,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_interval import homeassistant.util.dt as dt_util -from .const import ( +from ..const import ( ATTR_MANUFACTURER, CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -66,19 +50,16 @@ DEFAULT_TRACK_DEVICES, DEFAULT_TRACK_WIRED_CLIENTS, DOMAIN as UNIFI_DOMAIN, - LOGGER, PLATFORMS, UNIFI_WIRELESS_CLIENTS, ) -from .entity import UnifiEntity, UnifiEntityDescription -from .errors import AuthenticationRequired, CannotConnect +from ..entity import UnifiEntity, UnifiEntityDescription +from .websocket import UnifiWebsocket -RETRY_TIMER = 15 CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -CHECK_WEBSOCKET_INTERVAL = timedelta(minutes=1) -class UniFiController: +class UnifiHub: """Manages a single UniFi Network instance.""" def __init__( @@ -88,11 +69,8 @@ def __init__( self.hass = hass self.config_entry = config_entry self.api = api + self.websocket = UnifiWebsocket(hass, api, self.signal_reachable) - self.ws_task: asyncio.Task | None = None - self._cancel_websocket_check: CALLBACK_TYPE | None = None - - self.available = True self.wireless_clients = hass.data[UNIFI_WIRELESS_CLIENTS] self.site = config_entry.data[CONF_SITE_ID] @@ -163,10 +141,15 @@ def load_config_entry_options(self) -> None: @property def host(self) -> str: - """Return the host of this controller.""" + """Return the host of this hub.""" host: str = self.config_entry.data[CONF_HOST] return host + @property + def available(self) -> bool: + """Websocket connection state.""" + return self.websocket.available + @callback @staticmethod def register_platform( @@ -178,13 +161,24 @@ def register_platform( requires_admin: bool = False, ) -> None: """Register platform for UniFi entity management.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - if requires_admin and not controller.is_admin: + hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + if requires_admin and not hub.is_admin: return - controller.register_platform_add_entities( + hub.register_platform_add_entities( entity_class, descriptions, async_add_entities ) + @callback + def _async_should_add_entity( + self, description: UnifiEntityDescription, obj_id: str + ) -> bool: + """Check if entity should be added.""" + return bool( + (description.key, obj_id) not in self.known_objects + and description.allowed_fn(self, obj_id) + and description.supported_fn(self, obj_id) + ) + @callback def register_platform_add_entities( self, @@ -195,45 +189,45 @@ def register_platform_add_entities( """Subscribe to UniFi API handlers and create entities.""" @callback - def async_load_entities(description: UnifiEntityDescription) -> None: + def async_load_entities(descriptions: Iterable[UnifiEntityDescription]) -> None: """Load and subscribe to UniFi endpoints.""" - api_handler = description.api_handler_fn(self.api) @callback - def async_add_unifi_entity(obj_ids: list[str]) -> None: + def async_add_unifi_entities() -> 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) - ] + unifi_platform_entity(obj_id, self, description) + for description in descriptions + for obj_id in description.api_handler_fn(self.api) + if self._async_should_add_entity(description, obj_id) ) - async_add_unifi_entity(list(api_handler)) + async_add_unifi_entities() @callback - def async_create_entity(event: ItemEvent, obj_id: str) -> None: + def async_create_entity( + description: UnifiEntityDescription, event: ItemEvent, obj_id: str + ) -> None: """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)) + if self._async_should_add_entity(description, obj_id): + async_add_entities( + [unifi_platform_entity(obj_id, self, description)] + ) + + for description in descriptions: + description.api_handler_fn(self.api).subscribe( + partial(async_create_entity, description), ItemEvent.ADDED + ) self.config_entry.async_on_unload( async_dispatcher_connect( - self.hass, self.signal_options_update, async_options_updated + self.hass, + self.signal_options_update, + async_add_unifi_entities, ) ) - for description in descriptions: - async_load_entities(description) + async_load_entities(descriptions) @property def signal_reachable(self) -> str: @@ -277,9 +271,6 @@ async def initialize(self) -> None: self._cancel_heartbeat_check = async_track_time_interval( self.hass, self._async_check_for_stale, CHECK_HEARTBEAT_INTERVAL ) - self._cancel_websocket_check = async_track_time_interval( - self.hass, self._async_watch_websocket, CHECK_WEBSOCKET_INTERVAL - ) @callback def async_heartbeat( @@ -336,7 +327,7 @@ async def async_execute_command(now: datetime) -> None: @property def device_info(self) -> DeviceInfo: - """UniFi controller device info.""" + """UniFi Network device info.""" assert self.config_entry.unique_id is not None version: str | None = None @@ -369,60 +360,10 @@ async def async_config_entry_updated( If config entry is updated due to reauth flow the entry might already have been reset and thus is not available. """ - if not (controller := hass.data[UNIFI_DOMAIN].get(config_entry.entry_id)): + if not (hub := hass.data[UNIFI_DOMAIN].get(config_entry.entry_id)): return - controller.load_config_entry_options() - async_dispatcher_send(hass, controller.signal_options_update) - - @callback - def start_websocket(self) -> None: - """Start up connection to websocket.""" - - async def _websocket_runner() -> None: - """Start websocket.""" - try: - await self.api.start_websocket() - except (aiohttp.ClientConnectorError, aiounifi.WebsocketError): - LOGGER.error("Websocket disconnected") - self.available = False - async_dispatcher_send(self.hass, self.signal_reachable) - self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) - - self.ws_task = self.hass.loop.create_task(_websocket_runner()) - - @callback - def reconnect(self, log: bool = False) -> None: - """Prepare to reconnect UniFi session.""" - if log: - LOGGER.info("Will try to reconnect to UniFi Network") - self.hass.loop.create_task(self.async_reconnect()) - - async def async_reconnect(self) -> None: - """Try to reconnect UniFi Network session.""" - try: - async with asyncio.timeout(5): - await self.api.login() - self.start_websocket() - - if not self.available: - self.available = True - async_dispatcher_send(self.hass, self.signal_reachable) - - except ( - asyncio.TimeoutError, - aiounifi.BadGateway, - aiounifi.ServiceUnavailable, - aiounifi.AiounifiException, - ): - self.hass.loop.call_later(RETRY_TIMER, self.reconnect) - - @callback - def _async_watch_websocket(self, now: datetime) -> None: - """Watch timestamp for last received websocket message.""" - LOGGER.debug( - "Last received websocket timestamp: %s", - self.api.connectivity.ws_message_received, - ) + hub.load_config_entry_options() + async_dispatcher_send(hass, hub.signal_options_update) @callback def shutdown(self, event: Event) -> None: @@ -430,27 +371,15 @@ def shutdown(self, event: Event) -> None: Used as an argument to EventBus.async_listen_once. """ - if self.ws_task is not None: - self.ws_task.cancel() + self.websocket.stop() async def async_reset(self) -> bool: - """Reset this controller to default state. + """Reset this hub to default state. Will cancel any scheduled setup retry and will unload the config entry. """ - if self.ws_task is not None: - self.ws_task.cancel() - - _, pending = await asyncio.wait([self.ws_task], timeout=10) - - if pending: - LOGGER.warning( - "Unloading %s (%s) config entry. Task %s did not complete in time", - self.config_entry.title, - self.config_entry.domain, - self.ws_task, - ) + await self.websocket.stop_and_wait() unload_ok = await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS @@ -463,79 +392,8 @@ async def async_reset(self) -> bool: self._cancel_heartbeat_check() self._cancel_heartbeat_check = None - if self._cancel_websocket_check: - self._cancel_websocket_check() - self._cancel_websocket_check = None - if self._cancel_poe_command: self._cancel_poe_command() self._cancel_poe_command = None return True - - -async def get_unifi_controller( - hass: HomeAssistant, - config: MappingProxyType[str, Any], -) -> aiounifi.Controller: - """Create a controller object and verify authentication.""" - ssl_context: ssl.SSLContext | Literal[False] = False - - 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=False, cookie_jar=CookieJar(unsafe=True) - ) - - controller = aiounifi.Controller( - Configuration( - session, - host=config[CONF_HOST], - username=config[CONF_USERNAME], - password=config[CONF_PASSWORD], - port=config[CONF_PORT], - site=config[CONF_SITE_ID], - ssl_context=ssl_context, - ) - ) - - try: - async with asyncio.timeout(10): - await controller.login() - return controller - - except aiounifi.Unauthorized as err: - LOGGER.warning( - "Connected to UniFi Network at %s but not registered: %s", - config[CONF_HOST], - err, - ) - raise AuthenticationRequired from err - - except ( - asyncio.TimeoutError, - aiounifi.BadGateway, - aiounifi.Forbidden, - aiounifi.ServiceUnavailable, - aiounifi.RequestError, - aiounifi.ResponseError, - ) as err: - LOGGER.error( - "Error connecting to the UniFi Network at %s: %s", config[CONF_HOST], err - ) - raise CannotConnect from err - - except aiounifi.LoginRequired as err: - LOGGER.warning( - "Connected to UniFi Network at %s but login required: %s", - config[CONF_HOST], - err, - ) - raise AuthenticationRequired from err - - except aiounifi.AiounifiException as err: - LOGGER.exception("Unknown UniFi Network communication error occurred: %s", err) - raise AuthenticationRequired from err diff --git a/homeassistant/components/unifi/hub/websocket.py b/homeassistant/components/unifi/hub/websocket.py new file mode 100644 index 00000000000000..614d9a03e9e7dc --- /dev/null +++ b/homeassistant/components/unifi/hub/websocket.py @@ -0,0 +1,129 @@ +"""Websocket handler for UniFi Network integration.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timedelta + +import aiohttp +import aiounifi + +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from ..const import LOGGER + +RETRY_TIMER = 15 +CHECK_WEBSOCKET_INTERVAL = timedelta(minutes=1) + + +class UnifiWebsocket: + """Manages a single UniFi Network instance.""" + + def __init__( + self, hass: HomeAssistant, api: aiounifi.Controller, signal: str + ) -> None: + """Initialize the system.""" + self.hass = hass + self.api = api + self.signal = signal + + self.ws_task: asyncio.Task | None = None + self._cancel_websocket_check: CALLBACK_TYPE | None = None + + self.available = True + + @callback + def start(self) -> None: + """Start websocket handler.""" + self._cancel_websocket_check = async_track_time_interval( + self.hass, self._async_watch_websocket, CHECK_WEBSOCKET_INTERVAL + ) + self.start_websocket() + + @callback + def stop(self) -> None: + """Stop websocket handler.""" + if self._cancel_websocket_check: + self._cancel_websocket_check() + self._cancel_websocket_check = None + + if self.ws_task is not None: + self.ws_task.cancel() + + async def stop_and_wait(self) -> None: + """Stop websocket handler and await tasks.""" + if self._cancel_websocket_check: + self._cancel_websocket_check() + self._cancel_websocket_check = None + + if self.ws_task is not None: + self.stop() + + _, pending = await asyncio.wait([self.ws_task], timeout=10) + + if pending: + LOGGER.warning( + "Unloading UniFi Network (%s). Task %s did not complete in time", + self.api.connectivity.config.host, + self.ws_task, + ) + + @callback + def start_websocket(self) -> None: + """Start up connection to websocket.""" + + async def _websocket_runner() -> None: + """Start websocket.""" + try: + await self.api.start_websocket() + except (aiohttp.ClientConnectorError, aiohttp.WSServerHandshakeError): + LOGGER.error("Websocket setup failed") + except aiounifi.WebsocketError: + LOGGER.error("Websocket disconnected") + + self.available = False + async_dispatcher_send(self.hass, self.signal) + self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True) + + if not self.available: + self.available = True + async_dispatcher_send(self.hass, self.signal) + + self.ws_task = self.hass.loop.create_task(_websocket_runner()) + + @callback + def reconnect(self, log: bool = False) -> None: + """Prepare to reconnect UniFi session.""" + + async def _reconnect() -> None: + """Try to reconnect UniFi Network session.""" + try: + async with asyncio.timeout(5): + await self.api.login() + + except ( + TimeoutError, + aiounifi.BadGateway, + aiounifi.ServiceUnavailable, + aiounifi.AiounifiException, + ) as exc: + LOGGER.debug("Schedule reconnect to UniFi Network '%s'", exc) + self.hass.loop.call_later(RETRY_TIMER, self.reconnect) + + else: + self.start_websocket() + + if log: + LOGGER.info("Will try to reconnect to UniFi Network") + + self.hass.loop.create_task(_reconnect()) + + @callback + def _async_watch_websocket(self, now: datetime) -> None: + """Watch timestamp for last received websocket message.""" + LOGGER.debug( + "Last received websocket timestamp: %s", + self.api.connectivity.ws_message_received, + ) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index a4fb8d5eb33aad..a070c158772e5e 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .controller import UniFiController from .entity import ( HandlerT, UnifiEntity, @@ -28,19 +27,20 @@ async_wlan_available_fn, async_wlan_device_info_fn, ) +from .hub import UnifiHub @callback -def async_wlan_qr_code_image_fn(controller: UniFiController, wlan: Wlan) -> bytes: +def async_wlan_qr_code_image_fn(hub: UnifiHub, wlan: Wlan) -> bytes: """Calculate receiving data transfer value.""" - return controller.api.wlans.generate_wlan_qr_code(wlan) + return hub.api.wlans.generate_wlan_qr_code(wlan) @dataclass(frozen=True) class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - image_fn: Callable[[UniFiController, ApiItemT], bytes] + image_fn: Callable[[UnifiHub, ApiItemT], bytes] value_fn: Callable[[ApiItemT], str | None] @@ -59,7 +59,7 @@ class UnifiImageEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, entity_registry_enabled_default=False, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, device_info_fn=async_wlan_device_info_fn, @@ -68,8 +68,8 @@ class UnifiImageEntityDescription( name_fn=lambda wlan: "QR Code", object_fn=lambda api, obj_id: api.wlans[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"qr_code-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"qr_code-{obj_id}", image_fn=async_wlan_qr_code_image_fn, value_fn=lambda obj: obj.x_passphrase, ), @@ -82,7 +82,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, @@ -104,26 +104,26 @@ class UnifiImageEntity(UnifiEntity[HandlerT, ApiItemT], ImageEntity): def __init__( self, obj_id: str, - controller: UniFiController, + hub: UnifiHub, description: UnifiEntityDescription[HandlerT, ApiItemT], ) -> None: """Initiatlize UniFi Image entity.""" - super().__init__(obj_id, controller, description) - ImageEntity.__init__(self, controller.hass) + super().__init__(obj_id, hub, description) + ImageEntity.__init__(self, hub.hass) def image(self) -> bytes | None: """Return bytes of image.""" if self.current_image is None: description = self.entity_description - obj = description.object_fn(self.controller.api, self._obj_id) - self.current_image = description.image_fn(self.controller, obj) + obj = description.object_fn(self.hub.api, self._obj_id) + self.current_image = description.image_fn(self.hub, obj) return self.current_image @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: """Update entity state.""" description = self.entity_description - obj = description.object_fn(self.controller.api, self._obj_id) + obj = description.object_fn(self.hub.api, self._obj_id) if (value := description.value_fn(obj)) != self.previous_value: self.previous_value = value self.current_image = None diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f3092811227158..bcc25b22059e88 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,11 +4,12 @@ "codeowners": ["@Kane610"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", + "import_executor": true, "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==71"], + "requirements": ["aiounifi==72"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index a0cd3a7f1e7b26..ab76e662859e21 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -40,7 +40,6 @@ import homeassistant.util.dt as dt_util from .const import DEVICE_STATES -from .controller import UniFiController from .entity import ( HandlerT, UnifiEntity, @@ -51,44 +50,43 @@ async_wlan_available_fn, async_wlan_device_info_fn, ) +from .hub import UnifiHub @callback -def async_bandwidth_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: +def async_bandwidth_sensor_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if client is allowed.""" - if obj_id in controller.option_supported_clients: + if obj_id in hub.option_supported_clients: return True - return controller.option_allow_bandwidth_sensors + return hub.option_allow_bandwidth_sensors @callback -def async_uptime_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: +def async_uptime_sensor_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if client is allowed.""" - if obj_id in controller.option_supported_clients: + if obj_id in hub.option_supported_clients: return True - return controller.option_allow_uptime_sensors + return hub.option_allow_uptime_sensors @callback -def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: +def async_client_rx_value_fn(hub: UnifiHub, client: Client) -> float: """Calculate receiving data transfer value.""" - if controller.wireless_clients.is_wireless(client): + if hub.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: +def async_client_tx_value_fn(hub: UnifiHub, client: Client) -> float: """Calculate transmission data transfer value.""" - if controller.wireless_clients.is_wireless(client): + if hub.wireless_clients.is_wireless(client): return client.tx_bytes_r / 1000000 return client.wired_tx_bytes_r / 1000000 @callback -def async_client_uptime_value_fn( - controller: UniFiController, client: Client -) -> datetime: +def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime: """Calculate the uptime of the client.""" if client.uptime < 1000000000: return dt_util.now() - timedelta(seconds=client.uptime) @@ -96,23 +94,21 @@ def async_client_uptime_value_fn( @callback -def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: +def async_wlan_client_value_fn(hub: UnifiHub, wlan: Wlan) -> int: """Calculate the amount of clients connected to a wlan.""" return len( [ client.mac - for client in controller.api.clients.values() + for client in hub.api.clients.values() if client.essid == wlan.name and dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) - < controller.option_detection_time + < hub.option_detection_time ] ) @callback -def async_device_uptime_value_fn( - controller: UniFiController, device: Device -) -> datetime | None: +def async_device_uptime_value_fn(hub: UnifiHub, device: Device) -> datetime | None: """Calculate the approximate time the device started (based on uptime returned from API, in seconds).""" if device.uptime <= 0: # Library defaults to 0 if uptime is not provided, e.g. when offline @@ -131,29 +127,27 @@ def async_device_uptime_value_changed_fn( @callback -def async_device_outlet_power_supported_fn( - controller: UniFiController, obj_id: str -) -> bool: +def async_device_outlet_power_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet has the power property.""" # At this time, an outlet_caps value of 3 is expected to indicate that the outlet # supports metering - return controller.api.outlets[obj_id].caps == 3 + return hub.api.outlets[obj_id].caps == 3 @callback -def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) -> bool: +def async_device_outlet_supported_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if a device supports reading overall power metrics.""" - return controller.api.devices[obj_id].outlet_ac_power_budget is not None + return hub.api.devices[obj_id].outlet_ac_power_budget is not None @callback -def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bool: +def async_client_is_connected_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if client was last seen recently.""" - client = controller.api.clients[obj_id] + client = hub.api.clients[obj_id] if ( dt_util.utcnow() - dt_util.utc_from_timestamp(client.last_seen or 0) - > controller.option_detection_time + > hub.option_detection_time ): return False @@ -164,11 +158,11 @@ def async_client_is_connected_fn(controller: UniFiController, obj_id: str) -> bo class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] + value_fn: Callable[[UnifiHub, ApiItemT], datetime | float | str | None] @callback -def async_device_state_value_fn(controller: UniFiController, device: Device) -> str: +def async_device_state_value_fn(hub: UnifiHub, device: Device) -> str: """Retrieve the state of the device.""" return DEVICE_STATES[device.state] @@ -181,7 +175,7 @@ class UnifiSensorEntityDescription( ): """Class describing UniFi sensor entity.""" - is_connected_fn: Callable[[UniFiController, str], bool] | None = None + is_connected_fn: Callable[[UnifiHub, str], bool] | None = None # Custom function to determine whether a state change should be recorded value_changed_fn: Callable[ [StateType | date | datetime | Decimal, datetime | float | str | None], @@ -200,7 +194,7 @@ class UnifiSensorEntityDescription( has_entity_name=True, allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, - available_fn=lambda controller, _: controller.available, + available_fn=lambda hub, _: hub.available, device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, @@ -208,8 +202,8 @@ class UnifiSensorEntityDescription( name_fn=lambda _: "RX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, - supported_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, - unique_id_fn=lambda controller, obj_id: f"rx-{obj_id}", + supported_fn=lambda hub, _: hub.option_allow_bandwidth_sensors, + unique_id_fn=lambda hub, obj_id: f"rx-{obj_id}", value_fn=async_client_rx_value_fn, ), UnifiSensorEntityDescription[Clients, Client]( @@ -222,7 +216,7 @@ class UnifiSensorEntityDescription( has_entity_name=True, allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, - available_fn=lambda controller, _: controller.available, + available_fn=lambda hub, _: hub.available, device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, @@ -230,8 +224,8 @@ class UnifiSensorEntityDescription( name_fn=lambda _: "TX", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, - supported_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, - unique_id_fn=lambda controller, obj_id: f"tx-{obj_id}", + supported_fn=lambda hub, _: hub.option_allow_bandwidth_sensors, + unique_id_fn=lambda hub, obj_id: f"tx-{obj_id}", value_fn=async_client_tx_value_fn, ), UnifiSensorEntityDescription[Ports, Port]( @@ -241,7 +235,7 @@ class UnifiSensorEntityDescription( native_unit_of_measurement=UnitOfPower.WATT, has_entity_name=True, entity_registry_enabled_default=False, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -250,8 +244,8 @@ class UnifiSensorEntityDescription( name_fn=lambda port: f"{port.name} PoE Power", object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: f"poe_power-{obj_id}", + supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].port_poe, + unique_id_fn=lambda hub, obj_id: f"poe_power-{obj_id}", value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", ), UnifiSensorEntityDescription[Clients, Client]( @@ -262,15 +256,15 @@ class UnifiSensorEntityDescription( entity_registry_enabled_default=False, allowed_fn=async_uptime_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, - available_fn=lambda controller, obj_id: controller.available, + available_fn=lambda hub, obj_id: hub.available, device_info_fn=async_client_device_info_fn, event_is_on=None, event_to_subscribe=None, name_fn=lambda client: "Uptime", object_fn=lambda api, obj_id: api.clients[obj_id], should_poll=False, - supported_fn=lambda controller, _: controller.option_allow_uptime_sensors, - unique_id_fn=lambda controller, obj_id: f"uptime-{obj_id}", + supported_fn=lambda hub, _: hub.option_allow_uptime_sensors, + unique_id_fn=lambda hub, obj_id: f"uptime-{obj_id}", value_fn=async_client_uptime_value_fn, ), UnifiSensorEntityDescription[Wlans, Wlan]( @@ -278,7 +272,7 @@ class UnifiSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, state_class=SensorStateClass.MEASUREMENT, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.wlans, available_fn=async_wlan_available_fn, device_info_fn=async_wlan_device_info_fn, @@ -287,8 +281,8 @@ class UnifiSensorEntityDescription( name_fn=lambda wlan: None, object_fn=lambda api, obj_id: api.wlans[obj_id], should_poll=True, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"wlan_clients-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"wlan_clients-{obj_id}", value_fn=async_wlan_client_value_fn, ), UnifiSensorEntityDescription[Outlets, Outlet]( @@ -297,7 +291,7 @@ class UnifiSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfPower.WATT, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.outlets, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -307,7 +301,7 @@ class UnifiSensorEntityDescription( object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=True, supported_fn=async_device_outlet_power_supported_fn, - unique_id_fn=lambda controller, obj_id: f"outlet_power-{obj_id}", + unique_id_fn=lambda hub, obj_id: f"outlet_power-{obj_id}", value_fn=lambda _, obj: obj.power if obj.relay_state else "0", ), UnifiSensorEntityDescription[Devices, Device]( @@ -317,7 +311,7 @@ class UnifiSensorEntityDescription( native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=1, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -327,8 +321,8 @@ class UnifiSensorEntityDescription( object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, supported_fn=async_device_outlet_supported_fn, - unique_id_fn=lambda controller, obj_id: f"ac_power_budget-{obj_id}", - value_fn=lambda controller, device: device.outlet_ac_power_budget, + unique_id_fn=lambda hub, obj_id: f"ac_power_budget-{obj_id}", + value_fn=lambda hub, device: device.outlet_ac_power_budget, ), UnifiSensorEntityDescription[Devices, Device]( key="SmartPower AC power consumption", @@ -337,7 +331,7 @@ class UnifiSensorEntityDescription( native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=1, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -347,15 +341,15 @@ class UnifiSensorEntityDescription( object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, supported_fn=async_device_outlet_supported_fn, - unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", - value_fn=lambda controller, device: device.outlet_ac_power_consumption, + unique_id_fn=lambda hub, obj_id: f"ac_power_conumption-{obj_id}", + value_fn=lambda hub, device: device.outlet_ac_power_consumption, ), UnifiSensorEntityDescription[Devices, Device]( key="Device uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -364,8 +358,8 @@ class UnifiSensorEntityDescription( name_fn=lambda device: "Uptime", object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"device_uptime-{obj_id}", value_fn=async_device_uptime_value_fn, value_changed_fn=async_device_uptime_value_changed_fn, ), @@ -375,7 +369,7 @@ class UnifiSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTemperature.CELSIUS, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -385,7 +379,7 @@ class UnifiSensorEntityDescription( object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, supported_fn=lambda ctrlr, obj_id: ctrlr.api.devices[obj_id].has_temperature, - unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", + unique_id_fn=lambda hub, obj_id: f"device_temperature-{obj_id}", value_fn=lambda ctrlr, device: device.general_temperature, ), UnifiSensorEntityDescription[Devices, Device]( @@ -393,7 +387,7 @@ class UnifiSensorEntityDescription( device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, device_info_fn=async_device_device_info_fn, @@ -402,8 +396,8 @@ class UnifiSensorEntityDescription( name_fn=lambda device: "State", object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"device_state-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"device_state-{obj_id}", value_fn=async_device_state_value_fn, options=list(DEVICE_STATES.values()), ), @@ -416,7 +410,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, UnifiSensorEntity, ENTITY_DESCRIPTIONS ) @@ -443,19 +437,19 @@ def async_update_state(self, event: ItemEvent, obj_id: str) -> None: Update native_value. """ description = self.entity_description - obj = description.object_fn(self.controller.api, self._obj_id) + obj = description.object_fn(self.hub.api, self._obj_id) # Update the value only if value is considered to have changed relative to its previous state if description.value_changed_fn( - self.native_value, (value := description.value_fn(self.controller, obj)) + self.native_value, (value := description.value_fn(self.hub, obj)) ): self._attr_native_value = value if description.is_connected_fn is not None: # Send heartbeat if client is connected - if description.is_connected_fn(self.controller, self._obj_id): - self.controller.async_heartbeat( + if description.is_connected_fn(self.hub, self._obj_id): + self.hub.async_heartbeat( self._attr_unique_id, - dt_util.utcnow() + self.controller.option_detection_time, + dt_util.utcnow() + self.hub.option_detection_time, ) async def async_added_to_hass(self) -> None: @@ -467,7 +461,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - f"{self.controller.signal_heartbeat_missed}_{self.unique_id}", + f"{self.hub.signal_heartbeat_missed}_{self.unique_id}", self._make_disconnected, ) ) @@ -478,4 +472,4 @@ async def async_will_remove_from_hass(self) -> None: if self.entity_description.is_connected_fn is not None: # Remove heartbeat registration - self.controller.async_heartbeat(self._attr_unique_id) + self.hub.async_heartbeat(self._attr_unique_id) diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py index 06de01d822a50f..2017db4a0a8ce5 100644 --- a/homeassistant/components/unifi/services.py +++ b/homeassistant/components/unifi/services.py @@ -72,31 +72,31 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) - if mac == "": return - for controller in hass.data[UNIFI_DOMAIN].values(): + for hub in hass.data[UNIFI_DOMAIN].values(): if ( - not controller.available - or (client := controller.api.clients.get(mac)) is None + not hub.available + or (client := hub.api.clients.get(mac)) is None or client.is_wired ): continue - await controller.api.request(ClientReconnectRequest.create(mac)) + await hub.api.request(ClientReconnectRequest.create(mac)) async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> None: - """Remove select clients from controller. + """Remove select clients from UniFi Network. Validates based on: - Total time between first seen and last seen is less than 15 minutes. - Neither IP, hostname nor name is configured. """ - for controller in hass.data[UNIFI_DOMAIN].values(): - if not controller.available: + for hub in hass.data[UNIFI_DOMAIN].values(): + if not hub.available: continue clients_to_remove = [] - for client in controller.api.clients_all.values(): + for client in hub.api.clients_all.values(): if ( client.last_seen and client.first_seen @@ -110,4 +110,4 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) -> clients_to_remove.append(client.mac) if clients_to_remove: - await controller.api.request(ClientRemoveRequest.create(clients_to_remove)) + await hub.api.request(ClientRemoveRequest.create(clients_to_remove)) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 371676f4786c17..4a2785f0c173b5 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -44,8 +44,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er -from .const import ATTR_MANUFACTURER -from .controller import UNIFI_DOMAIN, UniFiController +from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN from .entity import ( HandlerT, SubscriptionT, @@ -56,25 +55,24 @@ async_device_device_info_fn, async_wlan_device_info_fn, ) +from .hub import UnifiHub CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKED) CLIENT_UNBLOCKED = (EventKey.WIRED_CLIENT_UNBLOCKED, EventKey.WIRELESS_CLIENT_UNBLOCKED) @callback -def async_block_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: +def async_block_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: """Check if client is allowed.""" - if obj_id in controller.option_supported_clients: + if obj_id in hub.option_supported_clients: return True - return obj_id in controller.option_block_clients + return obj_id in hub.option_block_clients @callback -def async_dpi_group_is_on_fn( - controller: UniFiController, dpi_group: DPIRestrictionGroup -) -> bool: +def async_dpi_group_is_on_fn(hub: UnifiHub, dpi_group: DPIRestrictionGroup) -> bool: """Calculate if all apps are enabled.""" - api = controller.api + api = hub.api return all( api.dpi_apps[app_id].enabled for app_id in dpi_group.dpiapp_ids or [] @@ -83,9 +81,7 @@ def async_dpi_group_is_on_fn( @callback -def async_dpi_group_device_info_fn( - controller: UniFiController, obj_id: str -) -> DeviceInfo: +def async_dpi_group_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for DPI group.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -97,11 +93,9 @@ def async_dpi_group_device_info_fn( @callback -def async_port_forward_device_info_fn( - controller: UniFiController, obj_id: str -) -> DeviceInfo: +def async_port_forward_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for port forward.""" - unique_id = controller.config_entry.unique_id + unique_id = hub.config_entry.unique_id assert unique_id is not None return DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -113,79 +107,67 @@ def async_port_forward_device_info_fn( async def async_block_client_control_fn( - controller: UniFiController, obj_id: str, target: bool + hub: UnifiHub, obj_id: str, target: bool ) -> None: """Control network access of client.""" - await controller.api.request(ClientBlockRequest.create(obj_id, not target)) + await hub.api.request(ClientBlockRequest.create(obj_id, not target)) -async def async_dpi_group_control_fn( - controller: UniFiController, obj_id: str, target: bool -) -> None: +async def async_dpi_group_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Enable or disable DPI group.""" - dpi_group = controller.api.dpi_groups[obj_id] + dpi_group = hub.api.dpi_groups[obj_id] await asyncio.gather( *[ - controller.api.request( - DPIRestrictionAppEnableRequest.create(app_id, target) - ) + hub.api.request(DPIRestrictionAppEnableRequest.create(app_id, target)) for app_id in dpi_group.dpiapp_ids or [] ] ) @callback -def async_outlet_supports_switching_fn( - controller: UniFiController, obj_id: str -) -> bool: +def async_outlet_supports_switching_fn(hub: UnifiHub, obj_id: str) -> bool: """Determine if an outlet supports switching.""" - outlet = controller.api.outlets[obj_id] + outlet = hub.api.outlets[obj_id] return outlet.has_relay or outlet.caps in (1, 3) -async def async_outlet_control_fn( - controller: UniFiController, obj_id: str, target: bool -) -> None: +async def async_outlet_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" mac, _, index = obj_id.partition("_") - device = controller.api.devices[mac] - await controller.api.request( + device = hub.api.devices[mac] + await hub.api.request( DeviceSetOutletRelayRequest.create(device, int(index), target) ) -async def async_poe_port_control_fn( - controller: UniFiController, obj_id: str, target: bool -) -> None: +async def async_poe_port_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control poe state.""" mac, _, index = obj_id.partition("_") - port = controller.api.ports[obj_id] + port = hub.api.ports[obj_id] on_state = "auto" if port.raw["poe_caps"] != 8 else "passthrough" state = on_state if target else "off" - controller.async_queue_poe_port_command(mac, int(index), state) + hub.async_queue_poe_port_command(mac, int(index), state) async def async_port_forward_control_fn( - controller: UniFiController, obj_id: str, target: bool + hub: UnifiHub, obj_id: str, target: bool ) -> None: """Control port forward state.""" - port_forward = controller.api.port_forwarding[obj_id] - await controller.api.request(PortForwardEnableRequest.create(port_forward, target)) + port_forward = hub.api.port_forwarding[obj_id] + await hub.api.request(PortForwardEnableRequest.create(port_forward, target)) -async def async_wlan_control_fn( - controller: UniFiController, obj_id: str, target: bool -) -> None: +async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: """Control outlet relay.""" - await controller.api.request(WlanEnableRequest.create(obj_id, target)) + await hub.api.request(WlanEnableRequest.create(obj_id, target)) @dataclass(frozen=True) class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" - control_fn: Callable[[UniFiController, str, bool], Coroutine[Any, Any, None]] - is_on_fn: Callable[[UniFiController, ApiItemT], bool] + control_fn: Callable[[UnifiHub, str, bool], Coroutine[Any, Any, None]] + is_on_fn: Callable[[UnifiHub, ApiItemT], bool] @dataclass(frozen=True) @@ -209,26 +191,26 @@ class UnifiSwitchEntityDescription( icon="mdi:ethernet", allowed_fn=async_block_client_allowed_fn, api_handler_fn=lambda api: api.clients, - available_fn=lambda controller, obj_id: controller.available, + available_fn=lambda hub, obj_id: hub.available, control_fn=async_block_client_control_fn, device_info_fn=async_client_device_info_fn, event_is_on=CLIENT_UNBLOCKED, event_to_subscribe=CLIENT_BLOCKED + CLIENT_UNBLOCKED, - is_on_fn=lambda controller, client: not client.blocked, + is_on_fn=lambda hub, client: not client.blocked, name_fn=lambda client: None, object_fn=lambda api, obj_id: api.clients[obj_id], only_event_for_state_change=True, should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"block-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"block-{obj_id}", ), UnifiSwitchEntityDescription[DPIRestrictionGroups, DPIRestrictionGroup]( key="DPI restriction", entity_category=EntityCategory.CONFIG, icon="mdi:network", - allowed_fn=lambda controller, obj_id: controller.option_dpi_restrictions, + allowed_fn=lambda hub, obj_id: hub.option_dpi_restrictions, api_handler_fn=lambda api: api.dpi_groups, - available_fn=lambda controller, obj_id: controller.available, + available_fn=lambda hub, obj_id: hub.available, control_fn=async_dpi_group_control_fn, custom_subscribe=lambda api: api.dpi_apps.subscribe, device_info_fn=async_dpi_group_device_info_fn, @@ -239,25 +221,25 @@ class UnifiSwitchEntityDescription( object_fn=lambda api, obj_id: api.dpi_groups[obj_id], should_poll=False, supported_fn=lambda c, obj_id: bool(c.api.dpi_groups[obj_id].dpiapp_ids), - unique_id_fn=lambda controller, obj_id: obj_id, + unique_id_fn=lambda hub, obj_id: obj_id, ), UnifiSwitchEntityDescription[Outlets, Outlet]( key="Outlet control", device_class=SwitchDeviceClass.OUTLET, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.outlets, available_fn=async_device_available_fn, control_fn=async_outlet_control_fn, device_info_fn=async_device_device_info_fn, event_is_on=None, event_to_subscribe=None, - is_on_fn=lambda controller, outlet: outlet.relay_state, + is_on_fn=lambda hub, outlet: outlet.relay_state, name_fn=lambda outlet: outlet.name, object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, supported_fn=async_outlet_supports_switching_fn, - unique_id_fn=lambda controller, obj_id: f"outlet-{obj_id}", + unique_id_fn=lambda hub, obj_id: f"outlet-{obj_id}", ), UnifiSwitchEntityDescription[PortForwarding, PortForward]( key="Port forward control", @@ -265,19 +247,19 @@ class UnifiSwitchEntityDescription( entity_category=EntityCategory.CONFIG, has_entity_name=True, icon="mdi:upload-network", - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.port_forwarding, - available_fn=lambda controller, obj_id: controller.available, + available_fn=lambda hub, obj_id: hub.available, control_fn=async_port_forward_control_fn, device_info_fn=async_port_forward_device_info_fn, event_is_on=None, event_to_subscribe=None, - is_on_fn=lambda controller, port_forward: port_forward.enabled, + is_on_fn=lambda hub, port_forward: port_forward.enabled, name_fn=lambda port_forward: f"{port_forward.name}", object_fn=lambda api, obj_id: api.port_forwarding[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"port_forward-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"port_forward-{obj_id}", ), UnifiSwitchEntityDescription[Ports, Port]( key="PoE port control", @@ -286,19 +268,19 @@ class UnifiSwitchEntityDescription( has_entity_name=True, entity_registry_enabled_default=False, icon="mdi:ethernet", - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.ports, available_fn=async_device_available_fn, control_fn=async_poe_port_control_fn, device_info_fn=async_device_device_info_fn, event_is_on=None, event_to_subscribe=None, - is_on_fn=lambda controller, port: port.poe_mode != "off", + is_on_fn=lambda hub, port: port.poe_mode != "off", name_fn=lambda port: f"{port.name} PoE", object_fn=lambda api, obj_id: api.ports[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, - unique_id_fn=lambda controller, obj_id: f"poe-{obj_id}", + supported_fn=lambda hub, obj_id: hub.api.ports[obj_id].port_poe, + unique_id_fn=lambda hub, obj_id: f"poe-{obj_id}", ), UnifiSwitchEntityDescription[Wlans, Wlan]( key="WLAN control", @@ -306,19 +288,19 @@ class UnifiSwitchEntityDescription( entity_category=EntityCategory.CONFIG, has_entity_name=True, icon="mdi:wifi-check", - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.wlans, - available_fn=lambda controller, _: controller.available, + available_fn=lambda hub, _: hub.available, control_fn=async_wlan_control_fn, device_info_fn=async_wlan_device_info_fn, event_is_on=None, event_to_subscribe=None, - is_on_fn=lambda controller, wlan: wlan.enabled, + is_on_fn=lambda hub, wlan: wlan.enabled, name_fn=lambda wlan: None, object_fn=lambda api, obj_id: api.wlans[obj_id], should_poll=False, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"wlan-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"wlan-{obj_id}", ), ) @@ -329,7 +311,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> No Introduced with release 2023.12. """ - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + hub: UnifiHub = hass.data[UNIFI_DOMAIN][config_entry.entry_id] ent_reg = er.async_get(hass) @callback @@ -344,10 +326,10 @@ def update_unique_id(obj_id: str, type_name: str) -> None: if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) - for obj_id in controller.api.outlets: + for obj_id in hub.api.outlets: update_unique_id(obj_id, "outlet") - for obj_id in controller.api.ports: + for obj_id in hub.api.ports: update_unique_id(obj_id, "poe") @@ -358,7 +340,7 @@ async def async_setup_entry( ) -> None: """Set up switches for UniFi Network integration.""" async_update_unique_id(hass, config_entry) - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, @@ -384,11 +366,11 @@ def async_initiate_state(self) -> None: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self.entity_description.control_fn(self.controller, self._obj_id, True) + await self.entity_description.control_fn(self.hub, self._obj_id, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self.entity_description.control_fn(self.controller, self._obj_id, False) + await self.entity_description.control_fn(self.hub, self._obj_id, False) @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: @@ -400,8 +382,8 @@ def async_update_state(self, event: ItemEvent, obj_id: str) -> None: return description = self.entity_description - obj = description.object_fn(self.controller.api, self._obj_id) - if (is_on := description.is_on_fn(self.controller, obj)) != self.is_on: + obj = description.object_fn(self.hub.api, self._obj_id) + if (is_on := description.is_on_fn(self.hub, obj)) != self.is_on: self._attr_is_on = is_on @callback @@ -416,7 +398,7 @@ def async_event_callback(self, event: Event) -> None: if event.key in description.event_to_subscribe: self._attr_is_on = event.key in description.event_is_on - self._attr_available = description.available_fn(self.controller, self._obj_id) + self._attr_available = description.available_fn(self.hub, self._obj_id) self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -425,7 +407,7 @@ async def async_added_to_hass(self) -> None: if self.entity_description.custom_subscribe is not None: self.async_on_remove( - self.entity_description.custom_subscribe(self.controller.api)( + self.entity_description.custom_subscribe(self.hub.api)( self.async_signalling_callback, ItemEvent.CHANGED ), ) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index a0d2da328a2dcd..b7f33b632b3ab0 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -21,13 +21,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .controller import UniFiController from .entity import ( UnifiEntity, UnifiEntityDescription, async_device_available_fn, async_device_device_info_fn, ) +from .hub import UnifiHub LOGGER = logging.getLogger(__name__) @@ -62,7 +62,7 @@ class UnifiUpdateEntityDescription( key="Upgrade device", device_class=UpdateDeviceClass.FIRMWARE, has_entity_name=True, - allowed_fn=lambda controller, obj_id: True, + allowed_fn=lambda hub, obj_id: True, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, control_fn=async_device_control_fn, @@ -73,8 +73,8 @@ class UnifiUpdateEntityDescription( object_fn=lambda api, obj_id: api.devices[obj_id], should_poll=False, state_fn=lambda api, device: device.state == 4, - supported_fn=lambda controller, obj_id: True, - unique_id_fn=lambda controller, obj_id: f"device_update-{obj_id}", + supported_fn=lambda hub, obj_id: True, + unique_id_fn=lambda hub, obj_id: f"device_update-{obj_id}", ), ) @@ -85,7 +85,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" - UniFiController.register_platform( + UnifiHub.register_platform( hass, config_entry, async_add_entities, @@ -103,7 +103,7 @@ class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): def async_initiate_state(self) -> None: """Initiate entity state.""" self._attr_supported_features = UpdateEntityFeature.PROGRESS - if self.controller.is_admin: + if self.hub.is_admin: self._attr_supported_features |= UpdateEntityFeature.INSTALL self.async_update_state(ItemEvent.ADDED, self._obj_id) @@ -112,7 +112,7 @@ async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self.entity_description.control_fn(self.controller.api, self._obj_id) + await self.entity_description.control_fn(self.hub.api, self._obj_id) @callback def async_update_state(self, event: ItemEvent, obj_id: str) -> None: @@ -122,7 +122,7 @@ def async_update_state(self, event: ItemEvent, obj_id: str) -> None: """ description = self.entity_description - obj = description.object_fn(self.controller.api, self._obj_id) - self._attr_in_progress = description.state_fn(self.controller.api, obj) + obj = description.object_fn(self.hub.api, self._obj_id) + self._attr_in_progress = description.state_fn(self.hub.api, obj) self._attr_installed_version = obj.version self._attr_latest_version = obj.upgrade_to_firmware or obj.version diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 174f60fd1352a5..c4a6bc880687b0 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -1,13 +1,18 @@ """UniFi Protect Platform.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging from aiohttp.client_exceptions import ServerDisconnectedError +from pyunifiprotect.data import Bootstrap from pyunifiprotect.exceptions import ClientError, NotAuthorized +# Import the test_util.anonymize module from the pyunifiprotect package +# in __init__ to ensure it gets imported in the executor since the +# diagnostics module will not be imported in the executor. +from pyunifiprotect.test_util.anonymize import anonymize_data # noqa: F401 + from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -21,6 +26,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( + AUTH_RETRIES, CONF_ALLOW_EA, DEFAULT_SCAN_INTERVAL, DEVICES_THAT_ADOPT, @@ -61,12 +67,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) try: - nvr_info = await protect.get_nvr() + bootstrap = await protect.get_bootstrap() + nvr_info = bootstrap.nvr except NotAuthorized as err: + retry_key = f"{entry.entry_id}_auth" + retries = hass.data.setdefault(DOMAIN, {}).get(retry_key, 0) + if retries < AUTH_RETRIES: + retries += 1 + hass.data[DOMAIN][retry_key] = retries + raise ConfigEntryNotReady from err raise ConfigEntryAuthFailed(err) from err - except (asyncio.TimeoutError, ClientError, ServerDisconnectedError) as err: + except (TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err + auth_user = bootstrap.users.get(bootstrap.auth_user_id) + if auth_user and auth_user.cloud_account: + ir.async_create_issue( + hass, + DOMAIN, + "cloud_user", + is_fixable=True, + is_persistent=False, + learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#local-user", + severity=IssueSeverity.ERROR, + translation_key="cloud_user", + data={"entry_id": entry.entry_id}, + ) + if nvr_info.version < MIN_REQUIRED_PROTECT_V: _LOGGER.error( OUTDATED_LOG_MESSAGE, @@ -102,7 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await _async_setup_entry(hass, entry, data_service) + await _async_setup_entry(hass, entry, data_service, bootstrap) except Exception as err: if await nvr_info.get_is_prerelease(): # If they are running a pre-release, its quite common for setup @@ -130,9 +157,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, data_service: ProtectData + hass: HomeAssistant, + entry: ConfigEntry, + data_service: ProtectData, + bootstrap: Bootstrap, ) -> None: - await async_migrate_data(hass, entry, data_service.api) + await async_migrate_data(hass, entry, data_service.api, bootstrap) await data_service.async_setup() if not data_service.last_update_success: diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 66767224de2b37..4408075468fadf 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -591,7 +591,8 @@ async def async_setup_entry( """Set up binary sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectDeviceBinarySensor, diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index cee4280507d97b..2046c12ddbdfac 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -113,7 +113,8 @@ async def async_setup_entry( """Discover devices on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectButton, diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 6d82e2fc9897ae..ca7abaac3c4547 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -113,7 +113,8 @@ async def async_setup_entry( """Discover cameras on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if not isinstance(device, UFPCamera): return # type: ignore[unreachable] diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index ec756118eb5b90..29718c8ef356d7 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -256,7 +256,8 @@ async def _async_get_nvr_data( errors = {} nvr_data = None try: - nvr_data = await protect.get_nvr() + bootstrap = await protect.get_bootstrap() + nvr_data = bootstrap.nvr except NotAuthorized as ex: _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" @@ -272,6 +273,10 @@ async def _async_get_nvr_data( ) errors["base"] = "protect_version" + auth_user = bootstrap.users.get(bootstrap.auth_user_id) + if auth_user and auth_user.cloud_account: + errors["base"] = "cloud_user" + return nvr_data, errors async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 3bc689666c7169..2982ca29c4a0d9 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -5,6 +5,9 @@ from homeassistant.const import Platform DOMAIN = "unifiprotect" +# some UniFi OS consoles have an unknown rate limit on auth +# if rate limit is triggered a 401 is returned +AUTH_RETRIES = 11 # ~12 hours of retries with the last waiting ~6 hours ATTR_EVENT_SCORE = "event_score" ATTR_EVENT_ID = "event_id" @@ -32,7 +35,7 @@ DEFAULT_PORT = 443 DEFAULT_ATTRIBUTION = "Powered by UniFi Protect Server" DEFAULT_BRAND = "Ubiquiti" -DEFAULT_SCAN_INTERVAL = 5 +DEFAULT_SCAN_INTERVAL = 20 DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 11782c42bee2eb..2825c2a4f3c4d6 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Generator, Iterable -from datetime import timedelta +from datetime import datetime, timedelta import logging from typing import Any, cast @@ -27,6 +27,7 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( + AUTH_RETRIES, CONF_DISABLE_RTSP, CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA, @@ -133,7 +134,7 @@ async def async_refresh(self, *_: Any, force: bool = False) -> None: try: updates = await self.api.update(force=force) except NotAuthorized: - if self._auth_failures < 10: + if self._auth_failures < AUTH_RETRIES: _LOGGER.exception("Auth error while updating") self._auth_failures += 1 else: @@ -281,6 +282,16 @@ def _async_process_updates(self, updates: Bootstrap | None) -> None: for device in self.get_by_types(DEVICES_THAT_ADOPT): self._async_signal_device_update(device) + @callback + def _async_poll(self, now: datetime) -> None: + """Poll the Protect API. + + If the websocket is connected, most of the time + this will be a no-op. If the websocket is disconnected, + this will trigger a reconnect and refresh. + """ + self._hass.async_create_task(self.async_refresh(), eager_start=True) + @callback def async_subscribe_device_id( self, mac: str, update_callback: Callable[[ProtectDeviceType], None] @@ -288,7 +299,7 @@ def async_subscribe_device_id( """Add an callback subscriber.""" if not self._subscriptions: self._unsub_interval = async_track_time_interval( - self._hass, self.async_refresh, self._update_interval + self._hass, self._async_poll, self._update_interval ) self._subscriptions.setdefault(mac, []).append(update_callback) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 891754265005d8..e068172037aa06 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -33,7 +33,8 @@ async def async_setup_entry( """Set up lights for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if device.model is ModelType.LIGHT and device.can_write( data.api.bootstrap.auth_user ): diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 57ade8ad220e7f..5bfa65fccf9d9c 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -34,7 +34,8 @@ async def async_setup_entry( """Set up locks on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if isinstance(device, Doorlock): async_add_entities([ProtectLock(data, device)]) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index edb2e28cc88e32..eba2b934e05837 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -37,11 +37,12 @@ } ], "documentation": "https://www.home-assistant.io/integrations/unifiprotect", + "import_executor": true, "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.23.2", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.23.3", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index e0f247eef72f6e..82e2ccd0be00d7 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -46,7 +46,8 @@ async def async_setup_entry( """Discover cameras with speakers on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if isinstance(device, Camera) and ( device.has_speaker or device.has_removable_speaker ): diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index 48aa7e0a6a24bc..32cac04797fb9b 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -47,6 +47,7 @@ class SimpleEventType(str, Enum): RING = "ring" MOTION = "motion" SMART = "smart" + AUDIO = "audio" class IdentifierType(str, Enum): @@ -64,21 +65,29 @@ class IdentifierTimeType(str, Enum): RANGE = "range" -EVENT_MAP = { - SimpleEventType.ALL: None, - SimpleEventType.RING: EventType.RING, - SimpleEventType.MOTION: EventType.MOTION, - SimpleEventType.SMART: EventType.SMART_DETECT, +EVENT_MAP: dict[SimpleEventType, set[EventType]] = { + SimpleEventType.ALL: { + EventType.RING, + EventType.MOTION, + EventType.SMART_DETECT, + EventType.SMART_DETECT_LINE, + EventType.SMART_AUDIO_DETECT, + }, + SimpleEventType.RING: {EventType.RING}, + SimpleEventType.MOTION: {EventType.MOTION}, + SimpleEventType.SMART: {EventType.SMART_DETECT, EventType.SMART_DETECT_LINE}, + SimpleEventType.AUDIO: {EventType.SMART_AUDIO_DETECT}, } EVENT_NAME_MAP = { SimpleEventType.ALL: "All Events", SimpleEventType.RING: "Ring Events", SimpleEventType.MOTION: "Motion Events", - SimpleEventType.SMART: "Smart Detections", + SimpleEventType.SMART: "Object Detections", + SimpleEventType.AUDIO: "Audio Detections", } -def get_ufp_event(event_type: SimpleEventType) -> EventType | None: +def get_ufp_event(event_type: SimpleEventType) -> set[EventType]: """Get UniFi Protect event type from SimpleEventType.""" return EVENT_MAP[event_type] @@ -132,6 +141,51 @@ def _format_duration(duration: timedelta) -> str: return formatted.strip() +@callback +def _get_object_name(event: Event | dict[str, Any]) -> str: + if isinstance(event, Event): + event = event.unifi_dict() + + names = [] + types = set(event["smartDetectTypes"]) + metadata = event.get("metadata") or {} + for thumb in metadata.get("detectedThumbnails", []): + thumb_type = thumb.get("type") + if thumb_type not in types: + continue + + types.remove(thumb_type) + if thumb_type == SmartDetectObjectType.VEHICLE.value: + attributes = thumb.get("attributes") or {} + color = attributes.get("color", {}).get("val", "") + vehicle_type = attributes.get("vehicleType", {}).get("val", "vehicle") + license_plate = metadata.get("licensePlate", {}).get("name") + + name = f"{color} {vehicle_type}".strip().title() + if license_plate: + types.remove(SmartDetectObjectType.LICENSE_PLATE.value) + name = f"{name}: {license_plate}" + names.append(name) + else: + smart_type = SmartDetectObjectType(thumb_type) + names.append(smart_type.name.title().replace("_", " ")) + + for raw in types: + smart_type = SmartDetectObjectType(raw) + names.append(smart_type.name.title().replace("_", " ")) + + return ", ".join(sorted(names)) + + +@callback +def _get_audio_name(event: Event | dict[str, Any]) -> str: + if isinstance(event, Event): + event = event.unifi_dict() + + smart_types = [SmartDetectObjectType(e) for e in event["smartDetectTypes"]] + return ", ".join([s.name.title().replace("_", " ") for s in smart_types]) + + class ProtectMediaSource(MediaSource): """Represents all UniFi Protect NVRs.""" @@ -384,7 +438,7 @@ async def _build_event( end = event.end else: event_id = event["id"] - event_type = event["type"] + event_type = EventType(event["type"]) start = from_js_time(event["start"]) end = from_js_time(event["end"]) @@ -393,19 +447,14 @@ async def _build_event( title = dt_util.as_local(start).strftime("%x %X") duration = end - start title += f" {_format_duration(duration)}" - if event_type == EventType.RING.value: + if event_type in EVENT_MAP[SimpleEventType.RING]: event_text = "Ring Event" - elif event_type == EventType.MOTION.value: + elif event_type in EVENT_MAP[SimpleEventType.MOTION]: event_text = "Motion Event" - elif event_type == EventType.SMART_DETECT.value: - if isinstance(event, Event): - smart_types = event.smart_detect_types - else: - smart_types = [ - SmartDetectObjectType(e) for e in event["smartDetectTypes"] - ] - smart_type_names = [s.name.title().replace("_", " ") for s in smart_types] - event_text = f"Smart Detection - {','.join(smart_type_names)}" + elif event_type in EVENT_MAP[SimpleEventType.SMART]: + event_text = f"Object Detection - {_get_object_name(event)}" + elif event_type in EVENT_MAP[SimpleEventType.AUDIO]: + event_text = f"Audio Detection - {_get_audio_name(event)}" title += f" {event_text}" nvr = data.api.bootstrap.nvr @@ -442,20 +491,13 @@ async def _build_events( start: datetime, end: datetime, camera_id: str | None = None, - event_type: EventType | None = None, + event_types: set[EventType] | None = None, reserve: bool = False, ) -> list[BrowseMediaSource]: """Build media source for a given range of time and event type.""" - if event_type is None: - types = [ - EventType.RING, - EventType.MOTION, - EventType.SMART_DETECT, - ] - else: - types = [event_type] - + event_types = event_types or get_ufp_event(SimpleEventType.ALL) + types = list(event_types) sources: list[BrowseMediaSource] = [] events = await data.api.get_events_raw( start=start, end=end, types=types, limit=data.max_events @@ -515,9 +557,8 @@ async def _build_recent( "start": now - timedelta(days=days), "end": now, "reserve": True, + "event_types": get_ufp_event(event_type), } - if event_type != SimpleEventType.ALL: - args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": @@ -646,9 +687,8 @@ async def _build_days( "start": start_dt, "end": end_dt, "reserve": False, + "event_types": get_ufp_event(event_type), } - if event_type != SimpleEventType.ALL: - args["event_type"] = get_ufp_event(event_type) camera: Camera | None = None if camera_id != "all": @@ -798,6 +838,9 @@ async def _build_camera( source.children.append( await self._build_events_type(data, camera_id, SimpleEventType.SMART) ) + source.children.append( + await self._build_events_type(data, camera_id, SimpleEventType.AUDIO) + ) if is_doorbell or has_smart: source.children.insert( diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index db1e82d99147fe..3a6dde653b4e78 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -3,47 +3,39 @@ import logging -from aiohttp.client_exceptions import ServerDisconnectedError from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel -from pyunifiprotect.exceptions import ClientError 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 entity_registry as er _LOGGER = logging.getLogger(__name__) async def async_migrate_data( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Run all valid UniFi Protect data migrations.""" _LOGGER.debug("Start Migrate: async_migrate_buttons") - await async_migrate_buttons(hass, entry, protect) + await async_migrate_buttons(hass, entry, protect, bootstrap) _LOGGER.debug("Completed Migrate: async_migrate_buttons") _LOGGER.debug("Start Migrate: async_migrate_device_ids") - await async_migrate_device_ids(hass, entry, protect) + await async_migrate_device_ids(hass, entry, protect, bootstrap) _LOGGER.debug("Completed Migrate: async_migrate_device_ids") -async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap: - """Get UniFi Protect bootstrap or raise appropriate HA error.""" - - try: - bootstrap = await protect.get_bootstrap() - except (TimeoutError, ClientError, ServerDisconnectedError) as err: - raise ConfigEntryNotReady from err - - return bootstrap - - async def async_migrate_buttons( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Migrate existing Reboot button unique IDs from {device_id} to {deivce_id}_reboot. @@ -63,7 +55,6 @@ async def async_migrate_buttons( _LOGGER.debug("No button entities need migration") return - bootstrap = await async_get_bootstrap(protect) count = 0 for button in to_migrate: device = bootstrap.get_device_from_id(button.unique_id) @@ -94,7 +85,10 @@ async def async_migrate_buttons( async def async_migrate_device_ids( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient + hass: HomeAssistant, + entry: ConfigEntry, + protect: ProtectApiClient, + bootstrap: Bootstrap, ) -> None: """Migrate unique IDs from {device_id}_{name} format to {mac}_{name} format. @@ -119,7 +113,6 @@ async def async_migrate_device_ids( _LOGGER.debug("No entities need migration to MAC address ID") return - bootstrap = await async_get_bootstrap(protect) count = 0 for entity in to_migrate: parts = entity.unique_id.split("_") diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 90201da98d8990..68ae3a66d10748 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -211,7 +211,8 @@ async def async_setup_entry( """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectNumbers, diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 49473744d06f99..ddc0a257c146f5 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -class EAConfirm(RepairsFlow): +class ProtectRepair(RepairsFlow): """Handler for an issue fixing flow.""" _api: ProtectApiClient @@ -34,14 +34,20 @@ def __init__(self, api: ProtectApiClient, entry: ConfigEntry) -> None: super().__init__() @callback - def _async_get_placeholders(self) -> dict[str, str] | None: + def _async_get_placeholders(self) -> dict[str, str]: issue_registry = async_get_issue_registry(self.hass) - description_placeholders = None + description_placeholders = {} if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders + description_placeholders = issue.translation_placeholders or {} + if issue.learn_more_url: + description_placeholders["learn_more"] = issue.learn_more_url return description_placeholders + +class EAConfirm(ProtectRepair): + """Handler for an issue fixing flow.""" + async def async_step_init( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: @@ -85,6 +91,33 @@ async def async_step_confirm( ) +class CloudAccount(ProtectRepair): + """Handler for an issue fixing flow.""" + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + if user_input is None: + placeholders = self._async_get_placeholders() + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + self._entry.async_start_reauth(self.hass) + return self.async_create_entry(data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -96,4 +129,9 @@ async def async_create_fix_flow( if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: api = async_create_api_client(hass, entry) return EAConfirm(api, entry) + elif data is not None and issue_id == "cloud_user": + entry_id = cast(str, data["entry_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + api = async_create_api_client(hass, entry) + return CloudAccount(api, entry) return ConfirmRepairFlow() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index eed49ac87e710d..5611ba79eca7b4 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -306,7 +306,8 @@ async def async_setup_entry( """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectSelects, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 5a52b45b62dfc1..c4d1f8a530da2e 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -617,7 +617,8 @@ async def async_setup_entry( """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectDeviceSensor, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index a345a504c4267d..eccf5829332b6f 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -37,7 +37,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry." + "protect_version": "Minimum required version is v1.20.0. Please upgrade UniFi Protect and then retry.", + "cloud_user": "Ubiquiti Cloud users are not Supported. Please use a Local only user." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -78,6 +79,17 @@ "ea_setup_failed": { "title": "Setup error using Early Access version", "description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please [downgrade to a stable version](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) of UniFi Protect to continue using the integration.\n\nError: {error}" + }, + "cloud_user": { + "title": "Ubiquiti Cloud Users are not Supported", + "fix_flow": { + "step": { + "confirm": { + "title": "Ubiquiti Cloud Users are not Supported", + "description": "Starting on July 22nd, 2024, Ubiquiti will require all cloud users to enroll in multi-factor authentication (MFA), which is incompatible with Home Assistant.\n\nIt would be best to migrate to using a [local user]({learn_more}) as soon as possible to keep the integration working.\n\nConfirming this repair will trigger a re-authentication flow to enter the needed authentication credentials." + } + } + } } }, "entity": { diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index ace769d7c43117..64890e17d4dbee 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -442,7 +442,8 @@ async def async_setup_entry( """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectSwitch, diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index cfc4ad5702fb23..2aebcfa1da9e66 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -61,7 +61,8 @@ async def async_setup_entry( """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + @callback + def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: entities = async_all_device_entities( data, ProtectDeviceText, diff --git a/homeassistant/components/upb/config_flow.py b/homeassistant/components/upb/config_flow.py index 318ba44f557483..6d85febed9fbc9 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -43,7 +43,7 @@ def _connected_callback(): upb.connect(_connected_callback) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(VALIDATE_TIMEOUT): await connected_event.wait() diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 6af9d85bc87cd3..2e546f8893f51b 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -72,7 +72,7 @@ async def device_discovered( try: async with asyncio.timeout(10): await device_discovered_event.wait() - except asyncio.TimeoutError as err: + except TimeoutError as err: raise ConfigEntryNotReady(f"Device not discovered: {usn}") from err finally: cancel_discovered_callback() diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index b32273a3f2465d..fa33d4b29d3fe4 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -206,20 +206,12 @@ async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowRes return self.async_abort(reason="discovery_ignored") LOGGER.debug("Updating entry: %s", entry.entry_id) - self.hass.config_entries.async_update_entry( + return self.async_update_reload_and_abort( entry, unique_id=unique_id, data={**entry.data, CONFIG_ENTRY_UDN: discovery_info.ssdp_udn}, + reason="config_entry_updated", ) - if entry.state == config_entries.ConfigEntryState.LOADED: - # Only reload when entry has state LOADED; when entry has state - # SETUP_RETRY, another load is started, - # causing the entry to be loaded twice. - LOGGER.debug("Reloading entry: %s", entry.entry_id) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - return self.async_abort(reason="config_entry_updated") # Store discovery. self._add_discovery(discovery_info) diff --git a/homeassistant/components/uprise_smart_shades/__init__.py b/homeassistant/components/uprise_smart_shades/__init__.py new file mode 100644 index 00000000000000..5f733f2120bd11 --- /dev/null +++ b/homeassistant/components/uprise_smart_shades/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Uprise Smart Shades.""" diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index b2358a4b0bd3db..916e9b1ea3275c 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -213,10 +213,11 @@ async def async_start(self, event: Event) -> None: """Start USB Discovery and run a manual scan.""" await self._async_scan_serial() - async def async_stop(self, event: Event) -> None: + @hass_callback + def async_stop(self, event: Event) -> None: """Stop USB Discovery.""" if self._request_debouncer: - await self._request_debouncer.async_shutdown() + self._request_debouncer.async_shutdown() async def _async_start_monitor(self) -> None: """Start monitoring hardware with pyudev.""" diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 71df5ba2c05e84..cd8c801d50c1d1 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bdraco"], "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/usb", + "import_executor": true, "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 4b99611684a526..a3b489dc55c1c1 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -266,8 +266,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> 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) + hass.config_entries.async_update_entry(config_entry, options=new, version=2) _LOGGER.info("Migration to version %s successful", config_entry.version) diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 3cf615caa3c45a..ea82a9b64fc0e0 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -13,9 +13,9 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, - Platform.NUMBER, ] diff --git a/homeassistant/components/vacuum/intent.py b/homeassistant/components/vacuum/intent.py new file mode 100644 index 00000000000000..c485686aa23ccf --- /dev/null +++ b/homeassistant/components/vacuum/intent.py @@ -0,0 +1,24 @@ +"""Intents for the vacuum integration.""" + + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START + +INTENT_VACUUM_START = "HassVacuumStart" +INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the vacuum intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler(INTENT_VACUUM_START, DOMAIN, SERVICE_START), + ) + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_VACUUM_RETURN_TO_BASE, DOMAIN, SERVICE_RETURN_TO_BASE + ), + ) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 3808bfb1202203..d12f7b4ffa1c30 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -1,20 +1,11 @@ """Support for Vallox ventilation units.""" from __future__ import annotations -from dataclasses import dataclass, field -from datetime import date import ipaddress import logging -from typing import Any, NamedTuple -from uuid import UUID - -from vallox_websocket_api import PROFILE as VALLOX_PROFILE, Vallox, ValloxApiException -from vallox_websocket_api.vallox import ( - get_model as _api_get_model, - get_next_filter_change_date as _api_get_next_filter_change_date, - get_sw_version as _api_get_sw_version, - get_uuid as _api_get_uuid, -) +from typing import NamedTuple + +from vallox_websocket_api import MetricData, Profile, Vallox, ValloxApiException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,7 +13,6 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,9 +25,6 @@ DEFAULT_FAN_SPEED_HOME, DEFAULT_NAME, DOMAIN, - METRIC_KEY_PROFILE_FAN_SPEED_AWAY, - METRIC_KEY_PROFILE_FAN_SPEED_BOOST, - METRIC_KEY_PROFILE_FAN_SPEED_HOME, STATE_SCAN_INTERVAL, ) @@ -59,10 +46,10 @@ ) PLATFORMS: list[str] = [ - Platform.SENSOR, - Platform.FAN, Platform.BINARY_SENSOR, + Platform.FAN, Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ] @@ -104,58 +91,7 @@ class ServiceMethodDetails(NamedTuple): } -@dataclass -class ValloxState: - """Describes the current state of the unit.""" - - metric_cache: dict[str, Any] = field(default_factory=dict) - profile: VALLOX_PROFILE = VALLOX_PROFILE.NONE - - def get_metric(self, metric_key: str) -> StateType: - """Return cached state value.""" - - if (value := self.metric_cache.get(metric_key)) is None: - return None - - if not isinstance(value, (str, int, float)): - return None - - return value - - @property - def model(self) -> str | None: - """Return the model, if any.""" - model = _api_get_model(self.metric_cache) - - if model == "Unknown": - return None - - return model - - @property - def sw_version(self) -> str: - """Return the SW version.""" - return _api_get_sw_version(self.metric_cache) - - @property - def uuid(self) -> UUID | None: - """Return cached UUID value.""" - uuid = _api_get_uuid(self.metric_cache) - if not isinstance(uuid, UUID): - raise TypeError - return uuid - - def get_next_filter_change_date(self) -> date | None: - """Return the next filter change date.""" - next_filter_change_date = _api_get_next_filter_change_date(self.metric_cache) - - if not isinstance(next_filter_change_date, date): - return None - - return next_filter_change_date - - -class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): # pylint: disable=hass-enforce-coordinator-module +class ValloxDataUpdateCoordinator(DataUpdateCoordinator[MetricData]): # pylint: disable=hass-enforce-coordinator-module """The DataUpdateCoordinator for Vallox.""" @@ -166,19 +102,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = Vallox(host) - async def async_update_data() -> ValloxState: + async def async_update_data() -> MetricData: """Fetch state update.""" _LOGGER.debug("Updating Vallox state cache") try: - metric_cache = await client.fetch_metrics() - profile = await client.get_profile() - + return await client.fetch_metric_data() except ValloxApiException as err: raise UpdateFailed("Error during state cache update") from err - return ValloxState(metric_cache, profile) - coordinator = ValloxDataUpdateCoordinator( hass, _LOGGER, @@ -227,7 +159,7 @@ class ValloxServiceHandler: """Services implementation.""" def __init__( - self, client: Vallox, coordinator: DataUpdateCoordinator[ValloxState] + self, client: Vallox, coordinator: DataUpdateCoordinator[MetricData] ) -> None: """Initialize the proxy.""" self._client = client @@ -240,9 +172,7 @@ async def async_set_profile_fan_speed_home( _LOGGER.debug("Setting Home fan speed to: %d%%", fan_speed) try: - await self._client.set_values( - {METRIC_KEY_PROFILE_FAN_SPEED_HOME: fan_speed} - ) + await self._client.set_fan_speed(Profile.HOME, fan_speed) return True except ValloxApiException as err: @@ -256,9 +186,7 @@ async def async_set_profile_fan_speed_away( _LOGGER.debug("Setting Away fan speed to: %d%%", fan_speed) try: - await self._client.set_values( - {METRIC_KEY_PROFILE_FAN_SPEED_AWAY: fan_speed} - ) + await self._client.set_fan_speed(Profile.AWAY, fan_speed) return True except ValloxApiException as err: @@ -272,9 +200,7 @@ async def async_set_profile_fan_speed_boost( _LOGGER.debug("Setting Boost fan speed to: %d%%", fan_speed) try: - await self._client.set_values( - {METRIC_KEY_PROFILE_FAN_SPEED_BOOST: fan_speed} - ) + await self._client.set_fan_speed(Profile.BOOST, fan_speed) return True except ValloxApiException as err: diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 00c25897d1c415..f919e67fa14ebd 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -38,7 +38,7 @@ def __init__( @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self.coordinator.data.get_metric(self.entity_description.metric_key) == 1 + return self.coordinator.data.get(self.entity_description.metric_key) == 1 @dataclass(frozen=True) diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index cfc5993797d149..6c6e36300234cb 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -32,7 +32,7 @@ async def validate_host(hass: HomeAssistant, host: str) -> None: raise InvalidHost(f"Invalid IP address: {host}") client = Vallox(host) - await client.get_info() + await client.fetch_metric_data() class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index ef6115a2894fa7..a2494c594f5887 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -2,7 +2,7 @@ from datetime import timedelta -from vallox_websocket_api import PROFILE as VALLOX_PROFILE +from vallox_websocket_api import Profile as VALLOX_PROFILE DOMAIN = "vallox" DEFAULT_NAME = "Vallox" @@ -30,8 +30,11 @@ } VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE = { + VALLOX_PROFILE.HOME: "Home", + VALLOX_PROFILE.AWAY: "Away", + VALLOX_PROFILE.BOOST: "Boost", + VALLOX_PROFILE.FIREPLACE: "Fireplace", VALLOX_PROFILE.EXTRA: "Extra", - **VALLOX_PROFILE_TO_PRESET_MODE_SETTABLE, } PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE = { diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index e58c3ebd88dc92..24448e6f53bb25 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -4,12 +4,7 @@ from collections.abc import Mapping from typing import Any, NamedTuple -from vallox_websocket_api import ( - PROFILE_TO_SET_FAN_SPEED_METRIC_MAP, - Vallox, - ValloxApiException, - ValloxInvalidInputException, -) +from vallox_websocket_api import Vallox, ValloxApiException, ValloxInvalidInputException from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -99,7 +94,7 @@ def __init__( @property def is_on(self) -> bool: """Return if device is on.""" - return self.coordinator.data.get_metric(METRIC_KEY_MODE) == MODE_ON + return self.coordinator.data.get(METRIC_KEY_MODE) == MODE_ON @property def preset_mode(self) -> str | None: @@ -112,19 +107,18 @@ def percentage(self) -> int | None: """Return the current speed as a percentage.""" vallox_profile = self.coordinator.data.profile - metric_key = PROFILE_TO_SET_FAN_SPEED_METRIC_MAP.get(vallox_profile) - if not metric_key: + try: + return _convert_to_int(self.coordinator.data.get_fan_speed(vallox_profile)) + except ValloxInvalidInputException: return None - return _convert_to_int(self.coordinator.data.get_metric(metric_key)) - @property def extra_state_attributes(self) -> Mapping[str, int | None]: """Return device specific state attributes.""" data = self.coordinator.data return { - attr.description: _convert_to_int(data.get_metric(attr.metric_key)) + attr.description: _convert_to_int(data.get(attr.metric_key)) for attr in EXTRA_STATE_ATTRIBUTES } @@ -153,7 +147,9 @@ async def async_turn_on( update_needed |= await self._async_set_preset_mode_internal(preset_mode) if percentage is not None: - update_needed |= await self._async_set_percentage_internal(percentage) + update_needed |= await self._async_set_percentage_internal( + percentage, preset_mode + ) if update_needed: # This state change affects other entities like sensors. Force an immediate update that @@ -202,19 +198,24 @@ async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: try: profile = PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE[preset_mode] await self._client.set_profile(profile) - self.coordinator.data.profile = profile except ValloxApiException as err: raise HomeAssistantError(f"Failed to set profile: {preset_mode}") from err return True - async def _async_set_percentage_internal(self, percentage: int) -> bool: + async def _async_set_percentage_internal( + self, percentage: int, preset_mode: str | None = None + ) -> bool: """Set fan speed percentage for current profile. Returns true if speed has been changed, false otherwise. """ - vallox_profile = self.coordinator.data.profile + vallox_profile = ( + PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE[preset_mode] + if preset_mode is not None + else self.coordinator.data.profile + ) try: await self._client.set_fan_speed(vallox_profile, percentage) diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index b45a2d598c9c7f..46cb765cc5ec9a 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -1,10 +1,10 @@ { "domain": "vallox", "name": "Vallox", - "codeowners": ["@andre-richter", "@slovdahl", "@viiru-"], + "codeowners": ["@andre-richter", "@slovdahl", "@viiru-", "@yozik04"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vallox", "iot_class": "local_polling", "loggers": ["vallox_websocket_api"], - "requirements": ["vallox-websocket-api==4.0.3"] + "requirements": ["vallox-websocket-api==5.1.0"] } diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index fa5dfff4a6d725..044bc7e0a43e6f 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -44,9 +44,7 @@ def __init__( def native_value(self) -> float | None: """Return the value reported by the sensor.""" if ( - value := self.coordinator.data.get_metric( - self.entity_description.metric_key - ) + value := self.coordinator.data.get(self.entity_description.metric_key) ) is None: return None diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index af5994b66d9e59..79dfeae8412fc0 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -58,7 +58,7 @@ def native_value(self) -> StateType | datetime: if (metric_key := self.entity_description.metric_key) is None: return None - value = self.coordinator.data.get_metric(metric_key) + value = self.coordinator.data.get(metric_key) if self.entity_description.round_ndigits is not None and isinstance( value, float @@ -90,7 +90,7 @@ class ValloxFanSpeedSensor(ValloxSensorEntity): @property def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" - fan_is_on = self.coordinator.data.get_metric(METRIC_KEY_MODE) == MODE_ON + fan_is_on = self.coordinator.data.get(METRIC_KEY_MODE) == MODE_ON return super().native_value if fan_is_on else 0 @@ -100,7 +100,7 @@ class ValloxFilterRemainingSensor(ValloxSensorEntity): @property def native_value(self) -> StateType | datetime: """Return the value reported by the sensor.""" - next_filter_change_date = self.coordinator.data.get_next_filter_change_date() + next_filter_change_date = self.coordinator.data.next_filter_change_date if next_filter_change_date is None: return None diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 8e7835e0bd76a7..fcc468c0fb21c4 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -41,9 +41,7 @@ def __init__( def is_on(self) -> bool | None: """Return true if the switch is on.""" if ( - value := self.coordinator.data.get_metric( - self.entity_description.metric_key - ) + value := self.coordinator.data.get(self.entity_description.metric_key) ) is None: return None return value == 1 @@ -93,12 +91,12 @@ async def async_setup_entry( """Set up the switches.""" data = hass.data[DOMAIN][entry.entry_id] - client = data["client"] - client.set_settable_address("A_CYC_BYPASS_LOCKED", int) async_add_entities( [ - ValloxSwitchEntity(data["name"], data["coordinator"], description, client) + ValloxSwitchEntity( + data["name"], data["coordinator"], description, data["client"] + ) for description in SWITCH_ENTITIES ] ) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index c23c1d5924e08d..609823b1310031 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -211,7 +211,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if os.path.isdir(cache_path): await hass.async_add_executor_job(shutil.rmtree, cache_path) # set the new version - config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry, version=2) _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index d6a5f540c06ccb..4c84eb687ad5ca 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,54 +1,71 @@ """Support for VELUX KLF 200 devices.""" -import logging - from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -DOMAIN = "velux" -DATA_VELUX = "data_velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, PLATFORMS CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the velux component.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config[DOMAIN], + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the velux component.""" + module = VeluxModule(hass, entry.data) try: - hass.data[DATA_VELUX] = VeluxModule(hass, config[DOMAIN]) - hass.data[DATA_VELUX].setup() - await hass.data[DATA_VELUX].async_start() + module.setup() + await module.async_start() except PyVLXException as ex: - _LOGGER.exception("Can't connect to velux interface: %s", ex) + LOGGER.exception("Can't connect to velux interface: %s", ex) return False - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) - ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = module + + 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) + + class VeluxModule: """Abstraction for velux component.""" @@ -63,7 +80,7 @@ def setup(self): async def on_hass_stop(event): """Close connection when hass stops.""" - _LOGGER.debug("Velux interface terminated") + LOGGER.debug("Velux interface terminated") await self.pyvlx.disconnect() async def async_reboot_gateway(service_call: ServiceCall) -> None: @@ -80,7 +97,7 @@ async def async_reboot_gateway(service_call: ServiceCall) -> None: async def async_start(self): """Start velux component.""" - _LOGGER.debug("Velux interface started") + LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py new file mode 100644 index 00000000000000..57791ea01dd16c --- /dev/null +++ b/homeassistant/components/velux/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Velux integration.""" +from typing import Any + +from pyvlx import PyVLX, PyVLXException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class VeluxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for velux.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry.""" + + def create_repair(error: str | None = None) -> None: + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Velux", + }, + ) + + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == config[CONF_HOST]: + create_repair() + return self.async_abort(reason="already_configured") + + pyvlx = PyVLX(host=config[CONF_HOST], password=config[CONF_PASSWORD]) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError): + create_repair("cannot_connect") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + create_repair("unknown") + return self.async_abort(reason="unknown") + + create_repair() + return self.async_create_entry( + title=config[CONF_HOST], + data=config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + pyvlx = PyVLX( + host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + ) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + errors["base"] = "cannot_connect" + LOGGER.debug("Cannot connect: %s", err) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + 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, + errors=errors, + ) diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py new file mode 100644 index 00000000000000..9a686adf920df2 --- /dev/null +++ b/homeassistant/components/velux/const.py @@ -0,0 +1,8 @@ +"""Constants for the Velux integration.""" +from logging import getLogger + +from homeassistant.const import Platform + +DOMAIN = "velux" +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +LOGGER = getLogger(__package__) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c8fb2aafb96c48..2162e63096a0ba 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -13,24 +13,22 @@ CoverEntity, CoverEntityFeature, ) +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 DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up cover(s) for Velux platform.""" entities = [] - for node in hass.data[DATA_VELUX].pyvlx.nodes: + module = hass.data[DOMAIN][config.entry_id] + for node in module.pyvlx.nodes: if isinstance(node, OpeningDevice): entities.append(VeluxCover(node)) async_add_entities(entities) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a6d63436ecf6fe..dae38f3d9bf463 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -6,25 +6,24 @@ from pyvlx import Intensity, LighteningDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +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 DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up light(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + async_add_entities( VeluxLight(node) - for node in hass.data[DATA_VELUX].pyvlx.nodes + for node in module.pyvlx.nodes if isinstance(node, LighteningDevice) ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 901034aa387ba3..c3576aca925c11 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,7 +1,8 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342"], + "codeowners": ["@Julius2342", "@DeerMaximum"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 20f94c74f0ba42..956663c23f1591 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -4,23 +4,22 @@ from typing import Any from homeassistant.components.scene import Scene +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 _LOGGER, DATA_VELUX +from . import DOMAIN PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the scenes for Velux platform.""" - entities = [VeluxScene(scene) for scene in hass.data[DATA_VELUX].pyvlx.scenes] + module = hass.data[DOMAIN][config.entry_id] + + entities = [VeluxScene(scene) for scene in module.pyvlx.scenes] async_add_entities(entities) @@ -29,7 +28,6 @@ class VeluxScene(Scene): def __init__(self, scene): """Init velux scene.""" - _LOGGER.info("Adding Velux scene: %s", scene) self.scene = scene @property diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 6a7e8c6e1ec963..3964c22efe261c 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "title": "Setup Velux", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Velux YAML configuration import cannot connect to server", + "description": "Configuring Velux using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the KLF 200." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Velux YAML configuration import failed with unknown error raised by pyvlx", + "description": "Configuring Velux using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + }, "services": { "reboot_gateway": { "name": "Reboot gateway", diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 58e350bd034472..2cee8f309aad8a 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -85,9 +85,10 @@ def update(self) -> None: else: self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - self._attr_native_value = self.vera_device.light - elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + elif self.vera_device.category in ( + veraApi.CATEGORY_LIGHT_SENSOR, + veraApi.CATEGORY_UV_SENSOR, + ): self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: self._attr_native_value = self.vera_device.humidity diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index dfd9d9cdc042ac..7d2ea7b7d6d436 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -108,7 +108,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=new_options) - entry.version = 2 + hass.config_entries.async_update_entry(entry, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index 0b39ecee604cc5..069da3dca64b42 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -71,6 +71,7 @@ "ODROID C2": "odroid-c2", "ODROID C4": "odroid-c4", "ODROID M1": "odroid-m1", + "ODROID M1S": "odroid-m1s", "ODROID N2": "odroid-n2", "ODROID XU4": "odroid-xu4", "Generic AArch64": "generic-aarch64", diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 97a557ef49f849..8cde8ea1036aa9 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -72,9 +72,24 @@ def ha_dev_type(device): return DEV_TYPE_TO_HA.get(device.device_type) -FILTER_LIFE_SUPPORTED = ["LV-PUR131S", "Core200S", "Core300S", "Core400S", "Core600S"] -AIR_QUALITY_SUPPORTED = ["LV-PUR131S", "Core300S", "Core400S", "Core600S"] -PM25_SUPPORTED = ["Core300S", "Core400S", "Core600S"] +FILTER_LIFE_SUPPORTED = [ + "LV-PUR131S", + "Core200S", + "Core300S", + "Core400S", + "Core600S", + "Vital100S", + "Vital200S", +] +AIR_QUALITY_SUPPORTED = [ + "LV-PUR131S", + "Core300S", + "Core400S", + "Core600S", + "Vital100S", + "Vital200S", +] +PM25_SUPPORTED = ["Core300S", "Core400S", "Core600S", "Vital100S", "Vital200S"] SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( VeSyncSensorEntityDescription( diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 4043cc865c75fc..ce439b9e628d17 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -84,7 +84,7 @@ async def async_http_request(hass, uri): return {"error": req.status} json_response = await req.json() return json_response - except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + except (TimeoutError, aiohttp.ClientError) as exc: _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc) except ValueError: _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint") diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index a2b2f3ac76983a..eec5f097535735 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,15 +1,13 @@ """The ViCare integration.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from contextlib import suppress -from dataclasses import dataclass import logging import os from typing import Any from PyViCare.PyViCare import PyViCare -from PyViCare.PyViCareDevice import Device from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidConfigurationError, @@ -22,36 +20,14 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.storage import STORAGE_DIR -from .const import ( - CONF_HEATING_TYPE, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - HEATING_TYPE_TO_CREATOR_METHOD, - PLATFORMS, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_DEVICE_CONFIG_LIST, - HeatingType, -) +from .const import DEFAULT_CACHE_DURATION, DEVICE_LIST, DOMAIN, PLATFORMS +from .types import ViCareDevice +from .utils import get_device _LOGGER = logging.getLogger(__name__) _TOKEN_FILENAME = "vicare_token.save" -@dataclass(frozen=True) -class ViCareRequiredKeysMixin: - """Mixin for required keys.""" - - value_getter: Callable[[Device], Any] - - -@dataclass(frozen=True) -class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): - """Mixin for required keys with setter.""" - - value_setter: Callable[[Device], bool] - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from config entry.""" _LOGGER.debug("Setting up ViCare component") @@ -69,10 +45,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def vicare_login(hass: HomeAssistant, entry_data: Mapping[str, Any]) -> PyViCare: +def vicare_login( + hass: HomeAssistant, + entry_data: Mapping[str, Any], + cache_duration=DEFAULT_CACHE_DURATION, +) -> PyViCare: """Login via PyVicare API.""" vicare_api = PyViCare() - vicare_api.setCacheDuration(DEFAULT_SCAN_INTERVAL) + vicare_api.setCacheDuration(cache_duration) vicare_api.initWithCredentials( entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], @@ -87,20 +67,25 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: vicare_api = vicare_login(hass, entry.data) device_config_list = get_supported_devices(vicare_api.devices) + if (number_of_devices := len(device_config_list)) > 1: + cache_duration = DEFAULT_CACHE_DURATION * number_of_devices + _LOGGER.debug( + "Found %s devices, adjusting cache duration to %s", + number_of_devices, + cache_duration, + ) + vicare_api = vicare_login(hass, entry.data, cache_duration) + device_config_list = get_supported_devices(vicare_api.devices) for device in device_config_list: _LOGGER.debug( "Found device: %s (online: %s)", device.getModel(), str(device.isOnline()) ) - # Currently we only support a single device - device = device_config_list[0] - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_config_list - hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device - hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( - device, - HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], - )() + hass.data[DOMAIN][entry.entry_id][DEVICE_LIST] = [ + ViCareDevice(config=device_config, api=get_device(entry, device_config)) + for device_config in device_config_list + ] async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index f3cf585b4708cf..a78b1fe5dabfab 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -27,9 +27,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixin from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) @@ -111,29 +111,28 @@ class ViCareBinarySensorEntityDescription( def _build_entities( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareBinarySensor]: """Create ViCare binary sensor entities for a device.""" - entities: list[ViCareBinarySensor] = _build_entities_for_device( - device, device_config - ) - entities.extend( - _build_entities_for_component( - get_circuits(device), device_config, CIRCUIT_SENSORS + entities: list[ViCareBinarySensor] = [] + for device in device_list: + entities.extend(_build_entities_for_device(device.api, device.config)) + entities.extend( + _build_entities_for_component( + get_circuits(device.api), device.config, CIRCUIT_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_burners(device), device_config, BURNER_SENSORS + entities.extend( + _build_entities_for_component( + get_burners(device.api), device.config, BURNER_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device), device_config, COMPRESSOR_SENSORS + entities.extend( + _build_entities_for_component( + get_compressors(device.api), device.config, COMPRESSOR_SENSORS + ) ) - ) return entities @@ -179,14 +178,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 8f11fdf0ac587c..ae32e66dff393a 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixinWithSet -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -48,19 +48,19 @@ class ViCareButtonEntityDescription( def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareButton]: """Create ViCare button entities for a device.""" return [ ViCareButton( - api, - device_config, + device.api, + device.config, description, ) + for device in device_list for description in BUTTON_DESCRIPTIONS - if is_supported(description.key, description, api) + if is_supported(description.key, description, device.api) ] @@ -70,14 +70,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare button entities.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index ba2665ac083d08..10cc1a15c9e25b 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -40,8 +40,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import HeatingProgram, ViCareDevice from .utils import get_burners, get_circuits, get_compressors _LOGGER = logging.getLogger(__name__) @@ -57,15 +58,6 @@ VICARE_MODE_FORCEDNORMAL = "forcedNormal" VICARE_MODE_OFF = "standby" -VICARE_PROGRAM_ACTIVE = "active" -VICARE_PROGRAM_COMFORT = "comfort" -VICARE_PROGRAM_ECO = "eco" -VICARE_PROGRAM_EXTERNAL = "external" -VICARE_PROGRAM_HOLIDAY = "holiday" -VICARE_PROGRAM_NORMAL = "normal" -VICARE_PROGRAM_REDUCED = "reduced" -VICARE_PROGRAM_STANDBY = "standby" - VICARE_HOLD_MODE_AWAY = "away" VICARE_HOLD_MODE_HOME = "home" VICARE_HOLD_MODE_OFF = "off" @@ -84,33 +76,28 @@ } VICARE_TO_HA_PRESET_HEATING = { - VICARE_PROGRAM_COMFORT: PRESET_COMFORT, - VICARE_PROGRAM_ECO: PRESET_ECO, - VICARE_PROGRAM_NORMAL: PRESET_HOME, - VICARE_PROGRAM_REDUCED: PRESET_SLEEP, + HeatingProgram.COMFORT: PRESET_COMFORT, + HeatingProgram.ECO: PRESET_ECO, + HeatingProgram.NORMAL: PRESET_HOME, + HeatingProgram.REDUCED: PRESET_SLEEP, } -HA_TO_VICARE_PRESET_HEATING = { - PRESET_COMFORT: VICARE_PROGRAM_COMFORT, - PRESET_ECO: VICARE_PROGRAM_ECO, - PRESET_HOME: VICARE_PROGRAM_NORMAL, - PRESET_SLEEP: VICARE_PROGRAM_REDUCED, -} +HA_TO_VICARE_PRESET_HEATING = {v: k for k, v in VICARE_TO_HA_PRESET_HEATING.items()} def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareClimate]: """Create ViCare climate entities for a device.""" return [ ViCareClimate( - api, + device.api, circuit, - device_config, + device.config, "heating", ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) ] @@ -120,8 +107,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] platform = entity_platform.async_get_current_platform() @@ -131,11 +116,12 @@ async def async_setup_entry( "set_vicare_mode", ) + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] + async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) @@ -219,7 +205,8 @@ def update(self) -> None: "heating_curve_shift" ] = self._circuit.getHeatingCurveShift() - self._attributes["vicare_modes"] = self._circuit.getModes() + with suppress(PyViCareNotSupportedFeatureError): + self._attributes["vicare_modes"] = self._circuit.getModes() self._current_action = False # Update the specific device attributes @@ -319,9 +306,9 @@ def set_preset_mode(self, preset_mode: str) -> None: _LOGGER.debug("Current preset %s", self._current_program) if self._current_program and self._current_program not in [ - VICARE_PROGRAM_NORMAL, - VICARE_PROGRAM_REDUCED, - VICARE_PROGRAM_STANDBY, + HeatingProgram.NORMAL, + HeatingProgram.REDUCED, + HeatingProgram.STANDBY, ]: # We can't deactivate "normal", "reduced" or "standby" _LOGGER.debug("deactivating %s", self._current_program) @@ -338,9 +325,9 @@ def set_preset_mode(self, preset_mode: str) -> None: _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) if target_program not in [ - VICARE_PROGRAM_NORMAL, - VICARE_PROGRAM_REDUCED, - VICARE_PROGRAM_STANDBY, + HeatingProgram.NORMAL, + HeatingProgram.REDUCED, + HeatingProgram.STANDBY, ]: # And we can't explicitly activate "normal", "reduced" or "standby", either _LOGGER.debug("activating %s", target_program) diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index 3ed81ab587a612..8b76344843aa32 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -1,7 +1,7 @@ """Constants for the ViCare integration.""" import enum -from homeassistant.const import Platform, UnitOfEnergy, UnitOfVolume +from homeassistant.const import Platform DOMAIN = "vicare" @@ -14,24 +14,20 @@ Platform.WATER_HEATER, ] -VICARE_DEVICE_CONFIG = "device_conf" -VICARE_DEVICE_CONFIG_LIST = "device_config_list" -VICARE_API = "api" +DEVICE_LIST = "device_list" VICARE_NAME = "ViCare" CONF_CIRCUIT = "circuit" CONF_HEATING_TYPE = "heating_type" -DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_CACHE_DURATION = 60 -VICARE_CUBIC_METER = "cubicMeter" +VICARE_PERCENT = "percent" +VICARE_W = "watt" +VICARE_KW = "kilowatt" +VICARE_WH = "wattHour" VICARE_KWH = "kilowattHour" - - -VICARE_UNIT_TO_UNIT_OF_MEASUREMENT = { - VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR, - VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS, -} +VICARE_CUBIC_METER = "cubicMeter" class HeatingType(enum.Enum): diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index aa5d08f92d8040..23a3c8640c54b2 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, VICARE_DEVICE_CONFIG_LIST +from .const import DEVICE_LIST, DOMAIN TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} @@ -18,10 +18,11 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - # Currently we only support a single device data = [] - for device in hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST]: - data.append(json.loads(await hass.async_add_executor_job(device.dump_secure))) + for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]: + data.append( + json.loads(await hass.async_add_executor_job(device.config.dump_secure)) + ) return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": data, diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index d4dd0437b04521..70fefb6e8db993 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -29,9 +29,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import HeatingProgram, ViCareDevice, ViCareRequiredKeysMixin from .utils import get_circuits, is_supported _LOGGER = logging.getLogger(__name__) @@ -89,11 +89,19 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), - value_setter=lambda api, value: api.setProgramTemperature("normal", value), - min_value_getter=lambda api: api.getProgramMinTemperature("normal"), - max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), - stepping_getter=lambda api: api.getProgramStepping("normal"), + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.NORMAL + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.NORMAL, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.NORMAL + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.NORMAL + ), + stepping_getter=lambda api: api.getProgramStepping(HeatingProgram.NORMAL), ), ViCareNumberEntityDescription( key="reduced_temperature", @@ -101,11 +109,19 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), - value_setter=lambda api, value: api.setProgramTemperature("reduced", value), - min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), - max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), - stepping_getter=lambda api: api.getProgramStepping("reduced"), + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.REDUCED + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.REDUCED, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.REDUCED + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.REDUCED + ), + stepping_getter=lambda api: api.getProgramStepping(HeatingProgram.REDUCED), ), ViCareNumberEntityDescription( key="comfort_temperature", @@ -113,28 +129,102 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM entity_category=EntityCategory.CONFIG, device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), - value_setter=lambda api, value: api.setProgramTemperature("comfort", value), - min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), - max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), - stepping_getter=lambda api: api.getProgramStepping("comfort"), + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.COMFORT + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.COMFORT, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.COMFORT + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.COMFORT + ), + stepping_getter=lambda api: api.getProgramStepping(HeatingProgram.COMFORT), + ), + ViCareNumberEntityDescription( + key="normal_heating_temperature", + translation_key="normal_heating_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.NORMAL_HEATING + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.NORMAL_HEATING, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.NORMAL_HEATING + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.NORMAL_HEATING + ), + stepping_getter=lambda api: api.getProgramStepping( + HeatingProgram.NORMAL_HEATING + ), + ), + ViCareNumberEntityDescription( + key="reduced_heating_temperature", + translation_key="reduced_heating_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.REDUCED_HEATING + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.REDUCED_HEATING, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.REDUCED_HEATING + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.REDUCED_HEATING + ), + stepping_getter=lambda api: api.getProgramStepping( + HeatingProgram.REDUCED_HEATING + ), + ), + ViCareNumberEntityDescription( + key="comfort_heating_temperature", + translation_key="comfort_heating_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram( + HeatingProgram.COMFORT_HEATING + ), + value_setter=lambda api, value: api.setProgramTemperature( + HeatingProgram.COMFORT_HEATING, value + ), + min_value_getter=lambda api: api.getProgramMinTemperature( + HeatingProgram.COMFORT_HEATING + ), + max_value_getter=lambda api: api.getProgramMaxTemperature( + HeatingProgram.COMFORT_HEATING + ), + stepping_getter=lambda api: api.getProgramStepping( + HeatingProgram.COMFORT_HEATING + ), ), ) def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareNumber]: - """Create ViCare number entities for a component.""" + """Create ViCare number entities for a device.""" return [ ViCareNumber( circuit, - device_config, + device.config, description, ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS if is_supported(description.key, description, circuit) ] @@ -146,14 +236,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare number devices.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index f5a7cfe182ad39..b36b363fc15a89 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -38,25 +38,39 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ViCareRequiredKeysMixin from .const import ( + DEVICE_LIST, DOMAIN, - VICARE_API, VICARE_CUBIC_METER, - VICARE_DEVICE_CONFIG, + VICARE_KW, VICARE_KWH, - VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, + VICARE_PERCENT, + VICARE_W, + VICARE_WH, ) from .entity import ViCareEntity +from .types import ViCareDevice, ViCareRequiredKeysMixin from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) VICARE_UNIT_TO_DEVICE_CLASS = { + VICARE_WH: SensorDeviceClass.ENERGY, VICARE_KWH: SensorDeviceClass.ENERGY, + VICARE_W: SensorDeviceClass.POWER, + VICARE_KW: SensorDeviceClass.POWER, VICARE_CUBIC_METER: SensorDeviceClass.GAS, } +VICARE_UNIT_TO_HA_UNIT = { + VICARE_PERCENT: PERCENTAGE, + VICARE_W: UnitOfPower.WATT, + VICARE_KW: UnitOfPower.KILO_WATT, + VICARE_WH: UnitOfEnergy.WATT_HOUR, + VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR, + VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS, +} + @dataclass(frozen=True) class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): @@ -145,6 +159,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_min_temperature", @@ -153,6 +168,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", @@ -167,6 +183,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisWeek(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_month", @@ -174,6 +191,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisMonth(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_heating_this_year", @@ -181,6 +199,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionDomesticHotWaterThisYear(), unit_getter=lambda api: api.getGasConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_consumption_heating_today", @@ -195,6 +214,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionHeatingThisWeek(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_month", @@ -202,6 +222,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionHeatingThisMonth(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_consumption_heating_this_year", @@ -209,6 +230,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasConsumptionHeatingThisYear(), unit_getter=lambda api: api.getGasConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_consumption_fuelcell_today", @@ -287,6 +309,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_currentyear", @@ -295,6 +318,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="gas_summary_consumption_heating_lastsevendays", @@ -303,6 +327,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentday", @@ -319,6 +344,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_currentyear", @@ -327,6 +353,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="hotwater_gas_summary_consumption_heating_lastsevendays", @@ -335,6 +362,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getGasSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentday", @@ -351,6 +379,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_currentyear", @@ -359,6 +388,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionHeatingCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_summary_consumption_heating_lastsevendays", @@ -367,6 +397,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionHeatingLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionHeatingUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentday", @@ -383,6 +414,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentMonth(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_dhw_summary_consumption_heating_currentyear", @@ -391,6 +423,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterCurrentYear(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="energy_summary_dhw_consumption_heating_lastsevendays", @@ -399,6 +432,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterLastSevenDays(), unit_getter=lambda api: api.getPowerSummaryConsumptionDomesticHotWaterUnit(), state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power_production_current", @@ -423,6 +457,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerProductionThisWeek(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power_production_this_month", @@ -431,6 +466,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerProductionThisMonth(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power_production_this_year", @@ -439,6 +475,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM value_getter=lambda api: api.getPowerProductionThisYear(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="solar storage temperature", @@ -473,6 +510,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getSolarPowerProductionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="solar power production this month", @@ -482,6 +520,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getSolarPowerProductionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="solar power production this year", @@ -491,6 +530,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getSolarPowerProductionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power consumption today", @@ -509,6 +549,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getPowerConsumptionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power consumption this month", @@ -518,6 +559,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getPowerConsumptionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="power consumption this year", @@ -527,6 +569,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM unit_getter=lambda api: api.getPowerConsumptionUnit(), device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="buffer top temperature", @@ -553,8 +596,83 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="ess_state_of_charge", + icon="mdi:home-battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getElectricalEnergySystemSOC(), + unit_getter=lambda api: api.getElectricalEnergySystemSOCUnit(), + ), + ViCareSensorEntityDescription( + key="ess_power_current", + translation_key="ess_power_current", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getElectricalEnergySystemPower(), + unit_getter=lambda api: api.getElectricalEnergySystemPowerUnit(), + ), + ViCareSensorEntityDescription( + key="ess_state", + translation_key="ess_state", + device_class=SensorDeviceClass.ENUM, + options=["charge", "discharge", "standby"], + value_getter=lambda api: api.getElectricalEnergySystemOperationState(), + ), + ViCareSensorEntityDescription( + key="pcc_transfer_power_exchange", + translation_key="pcc_transfer_power_exchange", + icon="mdi:transmission-tower", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getPointOfCommonCouplingTransferPowerExchange(), + ), + ViCareSensorEntityDescription( + key="pcc_energy_consumption", + translation_key="pcc_energy_consumption", + icon="mdi:transmission-tower-export", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPointOfCommonCouplingTransferConsumptionTotal(), + unit_getter=lambda api: api.getPointOfCommonCouplingTransferConsumptionTotalUnit(), + ), + ViCareSensorEntityDescription( + key="pcc_energy_feed_in", + translation_key="pcc_energy_feed_in", + icon="mdi:transmission-tower-import", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPointOfCommonCouplingTransferFeedInTotal(), + unit_getter=lambda api: api.getPointOfCommonCouplingTransferFeedInTotalUnit(), + ), + ViCareSensorEntityDescription( + key="photovoltaic_power_production_current", + translation_key="photovoltaic_power_production_current", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + value_getter=lambda api: api.getPhotovoltaicProductionCurrent(), + unit_getter=lambda api: api.getPhotovoltaicProductionCurrentUnit(), + ), + ViCareSensorEntityDescription( + key="photovoltaic_energy_production_today", + translation_key="photovoltaic_energy_production_today", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_getter=lambda api: api.getPhotovoltaicProductionCumulatedCurrentDay(), + unit_getter=lambda api: api.getPhotovoltaicProductionCumulatedUnit(), + ), + ViCareSensorEntityDescription( + key="photovoltaic_status", + translation_key="photovoltaic_status", + device_class=SensorDeviceClass.ENUM, + options=["ready", "production"], + value_getter=lambda api: _filter_pv_states(api.getPhotovoltaicStatus()), + ), ) + CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="supply_temperature", @@ -572,6 +690,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM translation_key="burner_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -580,6 +699,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -598,6 +718,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM translation_key="compressor_starts", icon="mdi:counter", value_getter=lambda api: api.getStarts(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -606,6 +727,7 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHours(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, ), ViCareSensorEntityDescription( @@ -614,7 +736,9 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass1(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="compressor_hours_loadclass2", @@ -622,7 +746,9 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass2(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="compressor_hours_loadclass3", @@ -630,7 +756,9 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass3(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="compressor_hours_loadclass4", @@ -638,7 +766,9 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass4(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="compressor_hours_loadclass5", @@ -646,7 +776,9 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM icon="mdi:counter", native_unit_of_measurement=UnitOfTime.HOURS, value_getter=lambda api: api.getHoursLoadClass5(), + entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, ), ViCareSensorEntityDescription( key="compressor_phase", @@ -658,28 +790,33 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM ) +def _filter_pv_states(state: str) -> str | None: + return None if state in ("nothing", "unknown") else state + + def _build_entities( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareSensor]: """Create ViCare sensor entities for a device.""" - entities: list[ViCareSensor] = _build_entities_for_device(device, device_config) - entities.extend( - _build_entities_for_component( - get_circuits(device), device_config, CIRCUIT_SENSORS + entities: list[ViCareSensor] = [] + for device in device_list: + entities.extend(_build_entities_for_device(device.api, device.config)) + entities.extend( + _build_entities_for_component( + get_circuits(device.api), device.config, CIRCUIT_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_burners(device), device_config, BURNER_SENSORS + entities.extend( + _build_entities_for_component( + get_burners(device.api), device.config, BURNER_SENSORS + ) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device), device_config, COMPRESSOR_SENSORS + entities.extend( + _build_entities_for_component( + get_compressors(device.api), device.config, COMPRESSOR_SENSORS + ) ) - ) return entities @@ -725,16 +862,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - api: PyViCareDevice = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config: PyViCareDeviceConfig = hass.data[DOMAIN][config_entry.entry_id][ - VICARE_DEVICE_CONFIG - ] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) @@ -761,6 +894,7 @@ def available(self) -> bool: def update(self) -> None: """Update state of sensor.""" + vicare_unit = None try: with suppress(PyViCareNotSupportedFeatureError): self._attr_native_value = self.entity_description.value_getter( @@ -769,13 +903,6 @@ def update(self) -> None: if self.entity_description.unit_getter: vicare_unit = self.entity_description.unit_getter(self._api) - if vicare_unit is not None: - self._attr_device_class = VICARE_UNIT_TO_DEVICE_CLASS.get( - vicare_unit - ) - self._attr_native_unit_of_measurement = ( - VICARE_UNIT_TO_UNIT_OF_MEASUREMENT.get(vicare_unit) - ) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: @@ -784,3 +911,12 @@ def update(self) -> None: _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) + + if vicare_unit is not None: + if ( + device_class := VICARE_UNIT_TO_DEVICE_CLASS.get(vicare_unit) + ) is not None: + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = VICARE_UNIT_TO_HA_UNIT.get( + vicare_unit + ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 96e43be68182ba..0541be9631fe2c 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,6 +80,15 @@ }, "comfort_temperature": { "name": "Comfort temperature" + }, + "normal_heating_temperature": { + "name": "[%key:component::vicare::entity::number::normal_temperature::name%]" + }, + "reduced_heating_temperature": { + "name": "[%key:component::vicare::entity::number::reduced_temperature::name%]" + }, + "comfort_heating_temperature": { + "name": "[%key:component::vicare::entity::number::comfort_temperature::name%]" } }, "sensor": { @@ -266,6 +275,39 @@ "volumetric_flow": { "name": "Volumetric flow" }, + "ess_power_current": { + "name": "Battery power" + }, + "ess_state": { + "name": "Battery state", + "state": { + "charge": "Charging", + "discharge": "Discharging", + "standby": "Standby" + } + }, + "pcc_current_power_exchange": { + "name": "Grid power exchange" + }, + "pcc_energy_consumption": { + "name": "Energy import from grid" + }, + "pcc_energy_feed_in": { + "name": "Energy export to grid" + }, + "photovoltaic_power_production_current": { + "name": "Solar power" + }, + "photovoltaic_energy_production_today": { + "name": "Solar energy production today" + }, + "photovoltaic_status": { + "name": "Solar state", + "state": { + "ready": "Standby", + "production": "Producing" + } + }, "supply_temperature": { "name": "Supply temperature" }, diff --git a/homeassistant/components/vicare/types.py b/homeassistant/components/vicare/types.py new file mode 100644 index 00000000000000..83b15a6bcf7fbd --- /dev/null +++ b/homeassistant/components/vicare/types.py @@ -0,0 +1,46 @@ +"""Types for the ViCare integration.""" +from collections.abc import Callable +from dataclasses import dataclass +import enum +from typing import Any + +from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig + + +class HeatingProgram(enum.StrEnum): + """ViCare preset heating programs. + + As listed in https://github.com/somm15/PyViCare/blob/63f9f7fea505fdf9a26c77c6cd0bff889abcdb05/PyViCare/PyViCareHeatingDevice.py#L606 + """ + + COMFORT = "comfort" + COMFORT_HEATING = "comfortHeating" + ECO = "eco" + NORMAL = "normal" + NORMAL_HEATING = "normalHeating" + REDUCED = "reduced" + REDUCED_HEATING = "reducedHeating" + STANDBY = "standby" + + +@dataclass(frozen=True) +class ViCareDevice: + """Dataclass holding the device api and config.""" + + config: PyViCareDeviceConfig + api: PyViCareDevice + + +@dataclass(frozen=True) +class ViCareRequiredKeysMixin: + """Mixin for required keys.""" + + value_getter: Callable[[PyViCareDevice], Any] + + +@dataclass(frozen=True) +class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): + """Mixin for required keys with setter.""" + + value_setter: Callable[[PyViCareDevice], bool] diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index a084eee383b3f4..649b1859442d93 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -2,16 +2,30 @@ import logging from PyViCare.PyViCareDevice import Device as PyViCareDevice +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError -from . import ViCareRequiredKeysMixin +from homeassistant.config_entries import ConfigEntry + +from .const import CONF_HEATING_TYPE, HEATING_TYPE_TO_CREATOR_METHOD, HeatingType +from .types import ViCareRequiredKeysMixin _LOGGER = logging.getLogger(__name__) +def get_device( + entry: ConfigEntry, device_config: PyViCareDeviceConfig +) -> PyViCareDevice: + """Get device for device config.""" + return getattr( + device_config, + HEATING_TYPE_TO_CREATOR_METHOD[HeatingType(entry.data[CONF_HEATING_TYPE])], + )() + + def is_supported( name: str, entity_description: ViCareRequiredKeysMixin, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 66a90ca065bc35..9a8fb7eb09245b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -24,8 +24,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG +from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .types import ViCareDevice from .utils import get_circuits _LOGGER = logging.getLogger(__name__) @@ -61,18 +62,19 @@ def _build_entities( - api: PyViCareDevice, - device_config: PyViCareDeviceConfig, + device_list: list[ViCareDevice], ) -> list[ViCareWater]: """Create ViCare domestic hot water entities for a device.""" + return [ ViCareWater( - api, + device.api, circuit, - device_config, + device.config, "domestic_hot_water", ) - for circuit in get_circuits(api) + for device in device_list + for circuit in get_circuits(device.api) ] @@ -82,14 +84,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare water heater platform.""" - api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] - device_config = hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG] + device_list = hass.data[DOMAIN][config_entry.entry_id][DEVICE_LIST] async_add_entities( await hass.async_add_executor_job( _build_entities, - api, - device_config, + device_list, ) ) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index e3de3caa99daa0..db3995772d4721 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -24,7 +24,7 @@ CONF_NAME, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_platform +from homeassistant.helpers import device_registry as dr, entity_platform from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( @@ -131,6 +131,10 @@ async def async_setup_entry( class VizioDevice(MediaPlayerEntity): """Media Player implementation which performs REST requests to device.""" + _attr_has_entity_name = True + _attr_name = None + _received_device_info = False + def __init__( self, config_entry: ConfigEntry, @@ -154,7 +158,7 @@ def __init__( CONF_ADDITIONAL_CONFIGS, [] ) self._device = device - self._max_volume = float(self._device.get_max_volume()) + self._max_volume = float(device.get_max_volume()) # Entity class attributes that will change with each update (we only include # the ones that are initialized differently from the defaults) @@ -162,10 +166,16 @@ def __init__( self._attr_supported_features = SUPPORTED_COMMANDS[device_class] # Entity class attributes that will not change - self._attr_name = name self._attr_icon = ICON[device_class] - self._attr_unique_id = self._config_entry.unique_id + unique_id = config_entry.unique_id + assert unique_id + self._attr_unique_id = unique_id self._attr_device_class = device_class + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="VIZIO", + name=name, + ) def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" @@ -195,15 +205,19 @@ async def async_update(self) -> None: ) self._attr_available = True - if not self._attr_device_info: - assert self._attr_unique_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="VIZIO", - model=await self._device.get_model_name(log_api_exception=False), - name=self._attr_name, - sw_version=await self._device.get_version(log_api_exception=False), + if not self._received_device_info: + device_reg = dr.async_get(self.hass) + assert self._config_entry.unique_id + device = device_reg.async_get_device( + identifiers={(DOMAIN, self._config_entry.unique_id)} ) + if device: + device_reg.async_update_device( + device.id, + model=await self._device.get_model_name(log_api_exception=False), + sw_version=await self._device.get_version(log_api_exception=False), + ) + self._received_device_info = True if not is_on: self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 5bdc8bee3ace6d..4ac2aae0a71c11 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -209,7 +209,7 @@ async def async_get_tts_audio(self, message, language, options): _LOGGER.error("Error receive %s from VoiceRSS", str(data, "utf-8")) return (None, None) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for VoiceRSS API") return (None, None) diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 11f70c631f1f69..a41f0965e8f51b 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -259,7 +259,7 @@ async def stt_stream(): if self.processing_tone_enabled: await self._play_processing_tone() - except asyncio.TimeoutError: + except TimeoutError: # Expected after caller hangs up _LOGGER.debug("Audio timeout") self._session_id = None @@ -304,7 +304,7 @@ async def stt_stream(): _LOGGER.debug("Pipeline finished") except PipelineNotFound: _LOGGER.warning("Pipeline not found") - except asyncio.TimeoutError: + except TimeoutError: # Expected after caller hangs up _LOGGER.debug("Pipeline timeout") self._session_id = None @@ -444,7 +444,7 @@ async def _send_tts(self, media_id: str) -> None: async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): # TTS audio is 16Khz 16-bit mono await self._async_send_audio(audio_bytes) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.warning("TTS timeout") raise err finally: diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 8c8fb85b8b3e53..bf502023e2b0c3 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -153,7 +153,7 @@ async def websocket_entity_info( try: async with asyncio.timeout(TIMEOUT_FETCH_WAKE_WORDS): wake_words = await entity.get_supported_wake_words() - except asyncio.TimeoutError: + except TimeoutError: connection.send_error( msg["id"], websocket_api.const.ERR_TIMEOUT, "Timeout fetching wake words" ) diff --git a/homeassistant/components/wake_word/models.py b/homeassistant/components/wake_word/models.py index 8e0699d97d02b4..c341df188cef0f 100644 --- a/homeassistant/components/wake_word/models.py +++ b/homeassistant/components/wake_word/models.py @@ -7,7 +7,13 @@ class WakeWord: """Wake word model.""" id: str + """Id of wake word model""" + name: str + """Name of wake word model""" + + phrase: str | None = None + """Wake word phrase used to trigger model""" @dataclass @@ -17,6 +23,9 @@ class DetectionResult: wake_word_id: str """Id of detected wake word""" + wake_word_phrase: str + """Normalized phrase for the detected wake word""" + timestamp: int | None """Timestamp of audio chunk with detected wake word""" diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index a6e284ff22b2ab..ce9008ef8bbc51 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.4.14"] + "requirements": ["wallbox==0.6.0"] } diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 82a853125ff1f5..7957297309027e 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -23,6 +23,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -149,7 +150,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA, - "async_set_operation_mode", + "async_handle_set_operation_mode", ) component.async_register_entity_service( SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA, "async_turn_off" @@ -359,6 +360,36 @@ async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" await self.hass.async_add_executor_job(self.set_operation_mode, operation_mode) + @final + async def async_handle_set_operation_mode(self, operation_mode: str) -> None: + """Handle a set target operation mode service call.""" + if self.operation_list is None: + raise ServiceValidationError( + f"Operation mode {operation_mode} not valid for " + f"entity {self.entity_id}. The operation list is not defined", + translation_domain=DOMAIN, + translation_key="operation_list_not_defined", + translation_placeholders={ + "entity_id": self.entity_id, + "operation_mode": operation_mode, + }, + ) + if operation_mode not in self.operation_list: + operation_list = ", ".join(self.operation_list) + raise ServiceValidationError( + f"Operation mode {operation_mode} not valid for " + f"entity {self.entity_id}. Valid " + f"operation modes are: {operation_list}", + translation_domain=DOMAIN, + translation_key="not_valid_operation_mode", + translation_placeholders={ + "entity_id": self.entity_id, + "operation_mode": operation_mode, + "operation_list": operation_list, + }, + ) + await self.async_set_operation_mode(operation_mode) + def turn_away_mode_on(self) -> None: """Turn away mode on.""" raise NotImplementedError() diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 1b3af02610cbfe..956cfe76b63bfb 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -71,5 +71,13 @@ "name": "[%key:common::action::turn_off%]", "description": "Turns water heater off." } + }, + "exceptions": { + "not_valid_operation_mode": { + "message": "Operation mode {operation_mode} is not valid for {entity_id}. Valid operation modes are: {operation_list}." + }, + "operation_list_not_defined": { + "message": "Operation mode {operation_mode} is not valid for {entity_id}. The operation list is not defined." + } } } diff --git a/homeassistant/components/weatherflow/config_flow.py b/homeassistant/components/weatherflow/config_flow.py index 5ce737810b0a61..d4ee319e70b16d 100644 --- a/homeassistant/components/weatherflow/config_flow.py +++ b/homeassistant/components/weatherflow/config_flow.py @@ -36,7 +36,7 @@ def _async_found(_): try: client.on(EVENT_DEVICE_DISCOVERED, _async_found) await future_event - except asyncio.TimeoutError: + except TimeoutError: return False return True diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py new file mode 100644 index 00000000000000..24b862433bd208 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -0,0 +1,34 @@ +"""The WeatherflowCloud integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WeatherFlowCloudDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up WeatherFlowCloud from a config entry.""" + + data_coordinator = WeatherFlowCloudDataUpdateCoordinator( + hass=hass, + api_token=entry.data[CONF_API_TOKEN], + ) + await data_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py new file mode 100644 index 00000000000000..85c1acbb807a14 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for WeatherflowCloud integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiohttp import ClientResponseError +import voluptuous as vol +from weatherflow4py.api import WeatherFlowRestAPI + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +async def _validate_api_token(api_token: str) -> dict[str, Any]: + """Validate the API token.""" + try: + async with WeatherFlowRestAPI(api_token) as api: + await api.async_get_stations() + except ClientResponseError as err: + if err.status == 401: + return {"base": "invalid_api_key"} + return {"base": "cannot_connect"} + return {} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for WeatherFlowCloud.""" + + VERSION = 1 + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle a flow for reauth.""" + errors = {} + + if user_input is not None: + api_token = user_input[CONF_API_TOKEN] + errors = await _validate_api_token(api_token) + if not errors: + # Update the existing entry and abort + if existing_entry := self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ): + return self.async_update_reload_and_abort( + existing_entry, + data={CONF_API_TOKEN: api_token}, + reason="reauth_successful", + ) + + return self.async_show_form( + step_id="reauth", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) + + 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(user_input) + api_token = user_input[CONF_API_TOKEN] + errors = await _validate_api_token(api_token) + if not errors: + return self.async_create_entry( + title="Weatherflow REST", + data={CONF_API_TOKEN: api_token}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/weatherflow_cloud/const.py b/homeassistant/components/weatherflow_cloud/const.py new file mode 100644 index 00000000000000..73245346b50988 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/const.py @@ -0,0 +1,8 @@ +"""Constants for the WeatherflowCloud integration.""" +import logging + +DOMAIN = "weatherflow_cloud" +LOGGER = logging.getLogger(__package__) + +ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api" +MANUFACTURER = "WeatherFlow" diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py new file mode 100644 index 00000000000000..7b9ddaafaaedca --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -0,0 +1,39 @@ +"""Data coordinator for WeatherFlow Cloud Data.""" +from datetime import timedelta + +from aiohttp import ClientResponseError +from weatherflow4py.api import WeatherFlowRestAPI +from weatherflow4py.models.unified import WeatherFlowData + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class WeatherFlowCloudDataUpdateCoordinator( + DataUpdateCoordinator[dict[int, WeatherFlowData]] +): + """Class to manage fetching REST Based WeatherFlow Forecast data.""" + + def __init__(self, hass: HomeAssistant, api_token: str) -> None: + """Initialize global WeatherFlow forecast data updater.""" + self.weather_api = WeatherFlowRestAPI(api_token=api_token) + + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[int, WeatherFlowData]: + """Fetch data from WeatherFlow Forecast.""" + try: + async with self.weather_api: + return await self.weather_api.get_all_data() + except ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed(err) from err + raise UpdateFailed(f"Update failed: {err}") from err diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json new file mode 100644 index 00000000000000..6abbeef02df40b --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "weatherflow_cloud", + "name": "WeatherflowCloud", + "codeowners": ["@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", + "iot_class": "cloud_polling", + "requirements": ["weatherflow4py==0.1.12"] +} diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json new file mode 100644 index 00000000000000..782b0dcf960068 --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up a WeatherFlow Forecast Station", + "data": { + "api_token": "Personal api token" + } + }, + "reauth": { + "description": "Reauthenticate with WeatherFlow", + "data": { + "api_token": "[%key:component::weatherflow_cloud::config::step::user::data::api_token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py new file mode 100644 index 00000000000000..b4ed6a3a9d8d3d --- /dev/null +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -0,0 +1,139 @@ +"""Support for WeatherFlow Forecast weather service.""" +from __future__ import annotations + +from weatherflow4py.models.unified import WeatherFlowData + +from homeassistant.components.weather import ( + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER +from .coordinator import WeatherFlowCloudDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + [ + WeatherFlowWeather(coordinator, station_id=station_id) + for station_id, data in coordinator.data.items() + ] + ) + + +class WeatherFlowWeather( + SingleCoordinatorWeatherEntity[WeatherFlowCloudDataUpdateCoordinator] +): + """Implementation of a WeatherFlow weather condition.""" + + _attr_attribution = ATTR_ATTRIBUTION + _attr_has_entity_name = True + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) + _attr_name = None + + def __init__( + self, + coordinator: WeatherFlowCloudDataUpdateCoordinator, + station_id: int, + ) -> None: + """Initialise the platform with a data instance and station.""" + super().__init__(coordinator) + + self.station_id = station_id + self._attr_unique_id = f"weatherflow_forecast_{station_id}" + + self._attr_device_info = DeviceInfo( + name=self.local_data.station.name, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{station_id}")}, + manufacturer=MANUFACTURER, + configuration_url=f"https://tempestwx.com/station/{station_id}/grid", + ) + + @property + def local_data(self) -> WeatherFlowData: + """Return the local weather data object for this station.""" + return self.coordinator.data[self.station_id] + + @property + def condition(self) -> str | None: + """Return current condition - required property.""" + return self.local_data.weather.current_conditions.icon.ha_icon + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return self.local_data.weather.current_conditions.air_temperature + + @property + def native_pressure(self) -> float | None: + """Return the Air Pressure @ Station.""" + return self.local_data.weather.current_conditions.station_pressure + + # + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self.local_data.weather.current_conditions.relative_humidity + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self.local_data.weather.current_conditions.wind_avg + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind direction.""" + return self.local_data.weather.current_conditions.wind_direction + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed in native units.""" + return self.local_data.weather.current_conditions.wind_gust + + @property + def native_dew_point(self) -> float | None: + """Return dew point.""" + return self.local_data.weather.current_conditions.dew_point + + @property + def uv_index(self) -> float | None: + """Return UV Index.""" + return self.local_data.weather.current_conditions.uv + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + return [x.ha_forecast for x in self.local_data.weather.forecast.daily] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + return [x.ha_forecast for x in self.local_data.weather.forecast.hourly] diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index 15ad5fa2ffb74e..307b2272d6c7e9 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -23,7 +23,7 @@ ) from .coordinator import WeatherKitDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/webmin/__init__.py b/homeassistant/components/webmin/__init__.py new file mode 100644 index 00000000000000..56f30d3b26f1f2 --- /dev/null +++ b/homeassistant/components/webmin/__init__.py @@ -0,0 +1,30 @@ +"""The Webmin integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import WebminUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Webmin from a config entry.""" + + coordinator = WebminUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + await coordinator.async_setup() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/webmin/config_flow.py b/homeassistant/components/webmin/config_flow.py new file mode 100644 index 00000000000000..783590d35baa3f --- /dev/null +++ b/homeassistant/components/webmin/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for Webmin.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any, cast +from xmlrpc.client import Fault + +from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) + +from .const import DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN +from .helpers import get_instance_from_options, get_sorted_mac_addresses + + +async def validate_user_input( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate user input.""" + # pylint: disable-next=protected-access + handler.parent_handler._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST]} + ) + instance, _ = get_instance_from_options(handler.parent_handler.hass, user_input) + try: + data = await instance.update() + except ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + raise SchemaFlowError("invalid_auth") from err + raise SchemaFlowError("cannot_connect") from err + except Fault as fault: + raise SchemaFlowError( + f"Fault {fault.faultCode}: {fault.faultString}" + ) from fault + except ClientConnectionError as err: + raise SchemaFlowError("cannot_connect") from err + except Exception as err: + raise SchemaFlowError("unknown") from err + + await cast(SchemaConfigFlowHandler, handler.parent_handler).async_set_unique_id( + get_sorted_mac_addresses(data)[0] + ) + return user_input + + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, max=65535, mode=selector.NumberSelectorMode.BOX + ) + ), + vol.Required(CONF_USERNAME): selector.TextSelector(), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=DEFAULT_SSL): selector.BooleanSelector(), + vol.Required( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): selector.BooleanSelector(), + } +) + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=CONFIG_SCHEMA, validate_user_input=validate_user_input + ), +} + + +class WebminConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Webmin.""" + + config_flow = CONFIG_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return str(options[CONF_HOST]) diff --git a/homeassistant/components/webmin/const.py b/homeassistant/components/webmin/const.py new file mode 100644 index 00000000000000..8bfadefedaa057 --- /dev/null +++ b/homeassistant/components/webmin/const.py @@ -0,0 +1,10 @@ +"""Constants for the Webmin integration.""" + +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) +DOMAIN = "webmin" + +DEFAULT_PORT = 10000 +DEFAULT_SSL = True +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/webmin/coordinator.py b/homeassistant/components/webmin/coordinator.py new file mode 100644 index 00000000000000..9a725ee2a770ca --- /dev/null +++ b/homeassistant/components/webmin/coordinator.py @@ -0,0 +1,53 @@ +"""Data update coordinator for the Webmin integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER +from .helpers import get_instance_from_options, get_sorted_mac_addresses + + +class WebminUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The Webmin data update coordinator.""" + + mac_address: str + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the Webmin data update coordinator.""" + + super().__init__( + hass, logger=LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.instance, base_url = get_instance_from_options(hass, config_entry.options) + + self.device_info = DeviceInfo( + configuration_url=base_url, + name=config_entry.options[CONF_HOST], + ) + + async def async_setup(self) -> None: + """Provide needed data to the device info.""" + mac_addresses = get_sorted_mac_addresses(self.data) + self.mac_address = mac_addresses[0] + self.device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, format_mac(mac_address)) + for mac_address in mac_addresses + } + self.device_info[ATTR_IDENTIFIERS] = { + (DOMAIN, format_mac(mac_address)) for mac_address in mac_addresses + } + + async def _async_update_data(self) -> dict[str, Any]: + return await self.instance.update() diff --git a/homeassistant/components/webmin/helpers.py b/homeassistant/components/webmin/helpers.py new file mode 100644 index 00000000000000..6d290183e76967 --- /dev/null +++ b/homeassistant/components/webmin/helpers.py @@ -0,0 +1,47 @@ +"""Helper functions for the Webmin integration.""" + +from collections.abc import Mapping +from typing import Any + +from webmin_xmlrpc.client import WebminInstance +from yarl import URL + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + + +def get_instance_from_options( + hass: HomeAssistant, options: Mapping[str, Any] +) -> tuple[WebminInstance, URL]: + """Retrieve a Webmin instance and the base URL from config options.""" + + base_url = URL.build( + scheme="https" if options[CONF_SSL] else "http", + user=options[CONF_USERNAME], + password=options[CONF_PASSWORD], + host=options[CONF_HOST], + port=int(options[CONF_PORT]), + ) + + return WebminInstance( + session=async_create_clientsession( + hass, + verify_ssl=options[CONF_VERIFY_SSL], + base_url=base_url, + ) + ), base_url + + +def get_sorted_mac_addresses(data: dict[str, Any]) -> list[str]: + """Return a sorted list of mac addresses.""" + return sorted( + [iface["ether"] for iface in data["active_interfaces"] if "ether" in iface] + ) diff --git a/homeassistant/components/webmin/icons.json b/homeassistant/components/webmin/icons.json new file mode 100644 index 00000000000000..2421974024a91e --- /dev/null +++ b/homeassistant/components/webmin/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "load_1m": { + "default": "mdi:chip" + }, + "load_5m": { + "default": "mdi:chip" + }, + "load_15m": { + "default": "mdi:chip" + }, + "mem_total": { + "default": "mdi:memory" + }, + "mem_free": { + "default": "mdi:memory" + }, + "swap_total": { + "default": "mdi:memory" + }, + "swap_free": { + "default": "mdi:memory" + } + } + } +} diff --git a/homeassistant/components/webmin/manifest.json b/homeassistant/components/webmin/manifest.json new file mode 100644 index 00000000000000..a15ca0a1f0d987 --- /dev/null +++ b/homeassistant/components/webmin/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "webmin", + "name": "Webmin", + "codeowners": ["@autinerd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/webmin", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["webmin"], + "requirements": ["webmin-xmlrpc==0.0.1"] +} diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py new file mode 100644 index 00000000000000..f20f8f9b625a6d --- /dev/null +++ b/homeassistant/components/webmin/sensor.py @@ -0,0 +1,112 @@ +"""Support for Webmin sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfInformation +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import WebminUpdateCoordinator + +SENSOR_TYPES: list[SensorEntityDescription] = [ + SensorEntityDescription( + key="load_1m", + translation_key="load_1m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="load_5m", + translation_key="load_5m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="load_15m", + translation_key="load_15m", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="mem_total", + translation_key="mem_total", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="mem_free", + translation_key="mem_free", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="swap_total", + translation_key="swap_total", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="swap_free", + translation_key="swap_free", + native_unit_of_measurement=UnitOfInformation.KIBIBYTES, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, + device_class=SensorDeviceClass.DATA_SIZE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Webmin sensors based on a config entry.""" + coordinator: WebminUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + WebminSensor(coordinator, description) + for description in SENSOR_TYPES + if description.key in coordinator.data + ) + + +class WebminSensor(CoordinatorEntity[WebminUpdateCoordinator], SensorEntity): + """Represents a Webmin sensor.""" + + entity_description: SensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, coordinator: WebminUpdateCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize a Webmin sensor.""" + + super().__init__(coordinator) + self.entity_description = description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.mac_address}_{description.key}" + + @property + def native_value(self) -> int | float: + """Return the state of the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json new file mode 100644 index 00000000000000..9963298d230cb2 --- /dev/null +++ b/homeassistant/components/webmin/strings.json @@ -0,0 +1,54 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Please enter the connection details of your instance.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "load_1m": { + "name": "Load (1m)" + }, + "load_5m": { + "name": "Load (5m)" + }, + "load_15m": { + "name": "Load (15m)" + }, + "mem_total": { + "name": "Memory total" + }, + "mem_free": { + "name": "Memory free" + }, + "swap_total": { + "name": "Swap total" + }, + "swap_free": { + "name": "Swap free" + } + } + } +} diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 830c0a4134a112..84675196d86ffb 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -32,6 +32,6 @@ ConnectionClosedOK, ConnectionRefusedError, WebOsTvCommandError, - asyncio.TimeoutError, + TimeoutError, asyncio.CancelledError, ) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 9152739852e2c3..ed8e1a6cc6edbd 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "loggers": ["aiowebostv"], "quality_scale": "platinum", - "requirements": ["aiowebostv==0.3.3"], + "requirements": ["aiowebostv==0.4.0"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 554d5e0b1d65ba..aefb6e774449ef 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -474,7 +474,7 @@ async def _async_fetch_image(self, url: str) -> tuple[bytes | None, str | None]: content = None websession = async_get_clientsession(self.hass) - with suppress(asyncio.TimeoutError): + with suppress(TimeoutError): async with asyncio.timeout(10): response = await websession.get(url, ssl=False) if response.status == HTTPStatus.OK: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index c088acc6e00e44..368785c17bc628 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import datetime as dt from functools import lru_cache, partial import json import logging @@ -356,7 +355,9 @@ def _send_handle_get_states_response( ) -> None: """Send handle get states response.""" connection.send_message( - construct_result_message(msg_id, b"[" + b",".join(serialized_states) + b"]") + construct_result_message( + msg_id, b"".join((b"[", b",".join(serialized_states), b"]")) + ) ) @@ -538,13 +539,12 @@ def handle_integration_setup_info( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" + setup_time: dict[str, float] = hass.data[DATA_SETUP_TIME] connection.send_result( msg["id"], [ - {"domain": integration, "seconds": timedelta.total_seconds()} - for integration, timedelta in cast( - dict[str, dt.timedelta], hass.data[DATA_SETUP_TIME] - ).items() + {"domain": integration, "seconds": seconds} + for integration, seconds in setup_time.items() ], ) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index e4540dfac350ed..aa7bcefadae456 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -1,7 +1,6 @@ """Connection session.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Hashable from contextvars import ContextVar from typing import TYPE_CHECKING, Any @@ -10,9 +9,9 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User -from homeassistant.components.http import current_request from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers.http import current_request from homeassistant.util.json import JsonValueType from . import const, messages @@ -266,7 +265,7 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: elif isinstance(err, vol.Invalid): code = const.ERR_INVALID_FORMAT err_message = vol.humanize.humanize_error(msg, err) - elif isinstance(err, asyncio.TimeoutError): + elif isinstance(err, TimeoutError): code = const.ERR_TIMEOUT err_message = "Timeout" elif isinstance(err, HomeAssistantError): diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index a148ed2be8d57c..b4c72d497cded6 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -45,6 +45,7 @@ def schedule_handler( hass.async_create_background_task( _handle_async_response(func, hass, connection, msg), task_name, + eager_start=True, ) return schedule_handler diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 416573d493cd8b..82c54a08136b49 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -16,6 +16,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.util.async_ import create_eager_task from homeassistant.util.json import json_loads from .auth import AUTH_REQUIRED_MESSAGE, AuthPhase @@ -282,7 +283,7 @@ async def async_handle(self) -> web.WebSocketResponse: try: async with asyncio.timeout(10): await wsock.prepare(request) - except asyncio.TimeoutError: + except TimeoutError: self._logger.warning("Timeout preparing request from %s", request.remote) return wsock @@ -310,7 +311,7 @@ async def async_handle(self) -> web.WebSocketResponse: # Auth Phase try: msg = await wsock.receive(10) - except asyncio.TimeoutError as err: + except TimeoutError as err: disconnect_warn = "Did not receive auth message within 10 seconds" raise Disconnect from err @@ -336,7 +337,7 @@ async def async_handle(self) -> web.WebSocketResponse: # We only start the writer queue after the auth phase is completed # since there is no need to queue messages before the auth phase self._connection = connection - self._writer_task = asyncio.create_task(self._writer(send_bytes_text)) + self._writer_task = create_eager_task(self._writer(send_bytes_text)) hass.data[DATA_CONNECTIONS] = hass.data.get(DATA_CONNECTIONS, 0) + 1 async_dispatcher_send(hass, SIGNAL_WEBSOCKET_CONNECTED) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 71a1eac62a842c..fa0b618b3f9380 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -7,6 +7,7 @@ "homekit": { "models": ["Socket", "Wemo"] }, + "import_executor": true, "iot_class": "local_push", "loggers": ["pywemo"], "requirements": ["pywemo==1.4.0"], diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 2c216100244ec3..a54610e9a8b4ef 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -55,6 +55,7 @@ def __init__( field_key must also match one of the field names inside the Options class. error_key: Name of the options.error key that corresponds to this error. message: Message for the Exception class. + """ super().__init__(message) self.field_key = field_key diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py index 42ffe7dd77e0ea..10b26801c10e9c 100644 --- a/homeassistant/components/whirlpool/__init__.py +++ b/homeassistant/components/whirlpool/__init__.py @@ -1,5 +1,4 @@ """The Whirlpool Appliances integration.""" -import asyncio from dataclasses import dataclass import logging @@ -35,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await auth.do_auth(store=False) - except (ClientError, asyncio.TimeoutError) as ex: + except (ClientError, TimeoutError) as ex: raise ConfigEntryNotReady("Cannot connect") from ex if not auth.is_access_token_valid(): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index fbbb670b6da53c..dbd3f9b6fd47bf 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Whirlpool Appliances integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import logging from typing import Any @@ -48,7 +47,7 @@ async def validate_input( auth = Auth(backend_selector, data[CONF_USERNAME], data[CONF_PASSWORD], session) try: await auth.do_auth() - except (asyncio.TimeoutError, ClientError) as exc: + except (TimeoutError, ClientError) as exc: raise CannotConnect from exc if not auth.is_access_token_valid(): @@ -92,7 +91,7 @@ async def async_step_reauth_confirm( await validate_input(self.hass, data) except InvalidAuth: errors["base"] = "invalid_auth" - except (CannotConnect, asyncio.TimeoutError): + except (CannotConnect, TimeoutError): errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py index 216c5d9335e1ce..3a6ba2a9f5b620 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -16,6 +16,7 @@ ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -72,21 +73,24 @@ class WizBulbEntity(WizToggleEntity, LightEntity): """Representation of WiZ Light bulb.""" _attr_name = None + _fixed_color_mode: ColorMode | None = None def __init__(self, wiz_data: WizData, name: str) -> None: """Initialize an WiZLight.""" super().__init__(wiz_data, name) bulb_type: BulbType = self._device.bulbtype features: Features = bulb_type.features - self._attr_supported_color_modes: set[ColorMode | str] = set() + color_modes = {ColorMode.ONOFF} if features.color: - self._attr_supported_color_modes.add( - RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels] - ) + color_modes.add(RGB_WHITE_CHANNELS_COLOR_MODE[bulb_type.white_channels]) if features.color_tmp: - self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) - if not self._attr_supported_color_modes and features.brightness: - self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) + color_modes.add(ColorMode.COLOR_TEMP) + if features.brightness: + color_modes.add(ColorMode.BRIGHTNESS) + self._attr_supported_color_modes = filter_supported_color_modes(color_modes) + if len(self._attr_supported_color_modes) == 1: + # If the light supports only a single color mode, set it now + self._attr_color_mode = next(iter(self._attr_supported_color_modes)) self._attr_effect_list = wiz_data.scenes if bulb_type.bulb_type != BulbClass.DW: kelvin = bulb_type.kelvin_range @@ -117,8 +121,6 @@ def _async_update_attrs(self) -> None: elif ColorMode.RGBW in color_modes and (rgbw := state.get_rgbw()) is not None: self._attr_rgbw_color = rgbw self._attr_color_mode = ColorMode.RGBW - else: - self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_effect = state.get_scene() super()._async_update_attrs() diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 73f49a2ad097ff..a8c09fc664ed91 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -3,13 +3,14 @@ import logging from httpx import RequestError -from wolf_smartset.token_auth import InvalidAuth -from wolf_smartset.wolf_client import FetchFailed, ParameterReadError, WolfClient +from wolf_comm.token_auth import InvalidAuth +from wolf_comm.wolf_client import FetchFailed, ParameterReadError, WolfClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -41,7 +42,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway_id, ) - wolf_client = WolfClient(username, password) + wolf_client = WolfClient( + username, + password, + client=get_async_client(hass=hass, verify_ssl=False), + ) parameters = await fetch_parameters_init(wolf_client, gateway_id, device_id) @@ -50,7 +55,6 @@ async def async_update_data(): try: nonlocal refetch_parameters nonlocal parameters - await wolf_client.update_session() if not await wolf_client.fetch_system_state_list(device_id, gateway_id): refetch_parameters = True raise UpdateFailed( @@ -95,7 +99,7 @@ async def async_update_data(): _LOGGER, name=DOMAIN, update_method=async_update_data, - update_interval=timedelta(minutes=1), + update_interval=timedelta(seconds=90), ) await coordinator.async_refresh() diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py index 63331fdbbd1a3d..bffc742f2029e5 100644 --- a/homeassistant/components/wolflink/config_flow.py +++ b/homeassistant/components/wolflink/config_flow.py @@ -3,8 +3,8 @@ from httpcore import ConnectError import voluptuous as vol -from wolf_smartset.token_auth import InvalidAuth -from wolf_smartset.wolf_client import WolfClient +from wolf_comm.token_auth import InvalidAuth +from wolf_comm.wolf_client import WolfClient from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json index 0d793385a3bbce..6b51c0fb2cbef7 100644 --- a/homeassistant/components/wolflink/manifest.json +++ b/homeassistant/components/wolflink/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wolflink", "iot_class": "cloud_polling", - "loggers": ["wolf_smartset"], - "requirements": ["wolf-smartset==0.1.11"] + "loggers": ["wolf_comm"], + "requirements": ["wolf-comm==0.0.6"] } diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 2135239b3eb457..2a030f69171ae5 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -1,7 +1,7 @@ """The Wolf SmartSet sensors.""" from __future__ import annotations -from wolf_smartset.models import ( +from wolf_comm.models import ( HoursParameter, ListItemParameter, Parameter, diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 3000570731b579..edada92aef41fa 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -1,7 +1,7 @@ """Sensor to indicate whether the current day is a workday.""" from __future__ import annotations -from holidays import HolidayBase, country_holidays, list_supported_countries +from holidays import HolidayBase, country_holidays from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE @@ -12,20 +12,16 @@ from .const import CONF_PROVINCE, DOMAIN, PLATFORMS -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Workday from a config entry.""" - - country: str | None = entry.options.get(CONF_COUNTRY) - province: str | None = entry.options.get(CONF_PROVINCE) +def _validate_country_and_province( + hass: HomeAssistant, entry: ConfigEntry, country: str | None, province: str | None +) -> None: + """Validate country and province.""" - if country and CONF_LANGUAGE not in entry.options: - cls: HolidayBase = country_holidays(country, subdiv=province) - default_language = cls.default_language - new_options = entry.options.copy() - new_options[CONF_LANGUAGE] = default_language - hass.config_entries.async_update_entry(entry, options=new_options) - - if country and country not in list_supported_countries(): + if not country: + return + try: + country_holidays(country) + except NotImplementedError as ex: async_create_issue( hass, DOMAIN, @@ -37,9 +33,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_placeholders={"title": entry.title}, data={"entry_id": entry.entry_id, "country": None}, ) - raise ConfigEntryError(f"Selected country {country} is not valid") + raise ConfigEntryError(f"Selected country {country} is not valid") from ex - if country and province and province not in list_supported_countries()[country]: + if not province: + return + try: + country_holidays(country, subdiv=province) + except NotImplementedError as ex: async_create_issue( hass, DOMAIN, @@ -48,17 +48,34 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: is_persistent=True, severity=IssueSeverity.ERROR, translation_key="bad_province", - translation_placeholders={CONF_COUNTRY: country, "title": entry.title}, + translation_placeholders={ + CONF_COUNTRY: country, + "title": entry.title, + }, data={"entry_id": entry.entry_id, "country": country}, ) raise ConfigEntryError( f"Selected province {province} for country {country} is not valid" - ) + ) from ex - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Workday from a config entry.""" + + country: str | None = entry.options.get(CONF_COUNTRY) + province: str | None = entry.options.get(CONF_PROVINCE) + + _validate_country_and_province(hass, entry, country, province) + if country and CONF_LANGUAGE not in entry.options: + cls: HolidayBase = country_holidays(country, subdiv=province) + default_language = cls.default_language + new_options = entry.options.copy() + new_options[CONF_LANGUAGE] = default_language + hass.config_entries.async_update_entry(entry, options=new_options) + + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 859d3710ca4a5e..9f7e829a244798 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Workday integration.""" from __future__ import annotations +from functools import partial from typing import Any from holidays import HolidayBase, country_holidays, list_supported_countries @@ -141,17 +142,6 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: raise RemoveDatesError("Incorrect date or name") -DATA_SCHEMA_SETUP = vol.Schema( - { - vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Optional(CONF_COUNTRY): CountrySelector( - CountrySelectorConfig( - countries=list(list_supported_countries(include_aliases=False)), - ) - ), - } -) - DATA_SCHEMA_OPT = vol.Schema( { vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( @@ -214,12 +204,25 @@ async def async_step_user( """Handle the user initial step.""" errors: dict[str, str] = {} + supported_countries = await self.hass.async_add_executor_job( + partial(list_supported_countries, include_aliases=False) + ) + if user_input is not None: self.data = user_input return await self.async_step_options() return self.async_show_form( step_id="user", - data_schema=DATA_SCHEMA_SETUP, + data_schema=vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Optional(CONF_COUNTRY): CountrySelector( + CountrySelectorConfig( + countries=list(supported_countries), + ) + ), + } + ), errors=errors, ) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 62819f74c2a6c2..96a3b53797ccab 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.43"] + "requirements": ["holidays==0.44"] } diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index 111acc5fff62fb..16073a3d862e4e 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -97,7 +97,7 @@ async def async_update(self) -> None: async with asyncio.timeout(self.timeout): auth = aiohttp.helpers.BasicAuth("admin", self.pin) mower_response = await session.get(self.url, auth=auth) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): if self.allow_unreachable is False: _LOGGER.error("Error connecting to mower at %s", self.url) diff --git a/homeassistant/components/wyoming/data.py b/homeassistant/components/wyoming/data.py index ea58181a7074d1..e333a740741ec3 100644 --- a/homeassistant/components/wyoming/data.py +++ b/homeassistant/components/wyoming/data.py @@ -1,10 +1,11 @@ """Base class for Wyoming providers.""" + from __future__ import annotations import asyncio from wyoming.client import AsyncTcpClient -from wyoming.info import Describe, Info, Satellite +from wyoming.info import Describe, Info from homeassistant.const import Platform @@ -23,14 +24,19 @@ def __init__(self, host: str, port: int, info: Info) -> None: self.host = host self.port = port self.info = info - platforms = [] + self.platforms = [] + + if (self.info.satellite is not None) and self.info.satellite.installed: + # Don't load platforms for satellite services, such as local wake + # word detection. + return + if any(asr.installed for asr in info.asr): - platforms.append(Platform.STT) + self.platforms.append(Platform.STT) if any(tts.installed for tts in info.tts): - platforms.append(Platform.TTS) + self.platforms.append(Platform.TTS) if any(wake.installed for wake in info.wake): - platforms.append(Platform.WAKE_WORD) - self.platforms = platforms + self.platforms.append(Platform.WAKE_WORD) def has_services(self) -> bool: """Return True if services are installed that Home Assistant can use.""" @@ -43,6 +49,12 @@ def has_services(self) -> bool: def get_name(self) -> str | None: """Return name of first installed usable service.""" + + # Wyoming satellite + # Must be checked first because satellites may contain wake services, etc. + if (self.info.satellite is not None) and self.info.satellite.installed: + return self.info.satellite.name + # ASR = automated speech recognition (speech-to-text) asr_installed = [asr for asr in self.info.asr if asr.installed] if asr_installed: @@ -58,15 +70,6 @@ def get_name(self) -> str | None: if wake_installed: return wake_installed[0].name - # satellite - satellite_installed: Satellite | None = None - - if (self.info.satellite is not None) and self.info.satellite.installed: - satellite_installed = self.info.satellite - - if satellite_installed: - return satellite_installed.name - return None @classmethod @@ -107,7 +110,7 @@ async def load_wyoming_info( if wyoming_info is not None: break # for - except (asyncio.TimeoutError, OSError, WyomingError): + except (TimeoutError, OSError, WyomingError): # Sleep and try again await asyncio.sleep(retry_wait) diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 14cf9f77683906..830ba5a3435b03 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["assist_pipeline"], "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==1.5.2"], + "requirements": ["wyoming==1.5.3"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index ea7a7d5df0ce59..9569c420a1edf1 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -1,4 +1,5 @@ """Support for Wyoming satellite services.""" + import asyncio from collections.abc import AsyncGenerator import io @@ -10,6 +11,7 @@ from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStart, AudioStop from wyoming.client import AsyncTcpClient from wyoming.error import Error +from wyoming.info import Describe, Info from wyoming.ping import Ping, Pong from wyoming.pipeline import PipelineStage, RunPipeline from wyoming.satellite import PauseSatellite, RunSatellite @@ -86,7 +88,9 @@ async def run(self) -> None: await self._connect_and_loop() except asyncio.CancelledError: raise # don't restart - except Exception: # pylint: disable=broad-exception-caught + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug("%s: %s", err.__class__.__name__, str(err)) + # Ensure sensor is off (before restart) self.device.set_is_active(False) @@ -197,6 +201,8 @@ async def _connect_and_loop(self) -> None: async def _run_pipeline_loop(self) -> None: """Run a pipeline one or more times.""" assert self._client is not None + client_info: Info | None = None + wake_word_phrase: str | None = None run_pipeline: RunPipeline | None = None send_ping = True @@ -209,6 +215,9 @@ async def _run_pipeline_loop(self) -> None: ) pending = {pipeline_ended_task, client_event_task} + # Update info from satellite + await self._client.write_event(Describe().event()) + while self.is_running and (not self.device.is_muted): if send_ping: # Ensure satellite is still connected @@ -230,6 +239,9 @@ async def _run_pipeline_loop(self) -> None: ) pending.add(pipeline_ended_task) + # Clear last wake word detection + wake_word_phrase = None + if (run_pipeline is not None) and run_pipeline.restart_on_end: # Automatically restart pipeline. # Used with "always on" streaming satellites. @@ -253,7 +265,7 @@ async def _run_pipeline_loop(self) -> None: elif RunPipeline.is_type(client_event.type): # Satellite requested pipeline run run_pipeline = RunPipeline.from_event(client_event) - self._run_pipeline_once(run_pipeline) + self._run_pipeline_once(run_pipeline, wake_word_phrase) elif ( AudioChunk.is_type(client_event.type) and self._is_pipeline_running ): @@ -265,6 +277,32 @@ async def _run_pipeline_loop(self) -> None: # Stop pipeline _LOGGER.debug("Client requested pipeline to stop") self._audio_queue.put_nowait(b"") + elif Info.is_type(client_event.type): + client_info = Info.from_event(client_event) + _LOGGER.debug("Updated client info: %s", client_info) + elif Detection.is_type(client_event.type): + detection = Detection.from_event(client_event) + wake_word_phrase = detection.name + + # Resolve wake word name/id to phrase if info is available. + # + # This allows us to deconflict multiple satellite wake-ups + # with the same wake word. + if (client_info is not None) and (client_info.wake is not None): + found_phrase = False + for wake_service in client_info.wake: + for wake_model in wake_service.models: + if wake_model.name == detection.name: + wake_word_phrase = ( + wake_model.phrase or wake_model.name + ) + found_phrase = True + break + + if found_phrase: + break + + _LOGGER.debug("Client detected wake word: %s", wake_word_phrase) else: _LOGGER.debug("Unexpected event from satellite: %s", client_event) @@ -274,7 +312,9 @@ async def _run_pipeline_loop(self) -> None: ) pending.add(client_event_task) - def _run_pipeline_once(self, run_pipeline: RunPipeline) -> None: + def _run_pipeline_once( + self, run_pipeline: RunPipeline, wake_word_phrase: str | None = None + ) -> None: """Run a pipeline once.""" _LOGGER.debug("Received run information: %s", run_pipeline) @@ -332,6 +372,7 @@ def _run_pipeline_once(self, run_pipeline: RunPipeline) -> None: volume_multiplier=self.device.volume_multiplier, ), device_id=self.device.device_id, + wake_word_phrase=wake_word_phrase, ), name="wyoming satellite pipeline", ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index da05e8c9fe112d..303a87e99bda44 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -1,4 +1,5 @@ """Support for Wyoming wake-word-detection services.""" + import asyncio from collections.abc import AsyncIterable import logging @@ -49,7 +50,9 @@ def __init__( wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) + wake_word.WakeWord( + id=ww.name, name=ww.description or ww.name, phrase=ww.phrase + ) for ww in wake_service.models ] self._attr_name = wake_service.name @@ -64,7 +67,11 @@ async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: if info is not None: wake_service = info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) + wake_word.WakeWord( + id=ww.name, + name=ww.description or ww.name, + phrase=ww.phrase, + ) for ww in wake_service.models ] @@ -140,6 +147,7 @@ async def next_chunk(): return wake_word.DetectionResult( wake_word_id=detection.name, + wake_word_phrase=self._get_phrase(detection.name), timestamp=detection.timestamp, queued_audio=queued_audio, ) @@ -183,3 +191,14 @@ async def next_chunk(): _LOGGER.exception("Error processing audio stream: %s", err) return None + + def _get_phrase(self, model_id: str) -> str: + """Get wake word phrase for model id.""" + for ww_model in self._supported_wake_words: + if not ww_model.phrase: + continue + + if ww_model.id == model_id: + return ww_model.phrase + + return model_id diff --git a/homeassistant/components/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 30a6c3bc7005ac..67e53e326ee238 100644 --- a/homeassistant/components/xbox/manifest.json +++ b/homeassistant/components/xbox/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/xbox", + "import_executor": true, "iot_class": "cloud_polling", "requirements": ["xbox-webapi==2.0.11"] } diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 2894b8d2f3f939..cd6f7b453bb851 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -29,6 +29,10 @@ from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { + XiaomiBinarySensorDeviceClass.BATTERY: BinarySensorEntityDescription( + key=XiaomiBinarySensorDeviceClass.BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + ), XiaomiBinarySensorDeviceClass.DOOR: BinarySensorEntityDescription( key=XiaomiBinarySensorDeviceClass.DOOR, device_class=BinarySensorDeviceClass.DOOR, @@ -49,6 +53,10 @@ key=XiaomiBinarySensorDeviceClass.OPENING, device_class=BinarySensorDeviceClass.OPENING, ), + XiaomiBinarySensorDeviceClass.POWER: BinarySensorEntityDescription( + key=XiaomiBinarySensorDeviceClass.POWER, + device_class=BinarySensorDeviceClass.POWER, + ), XiaomiBinarySensorDeviceClass.SMOKE: BinarySensorEntityDescription( key=XiaomiBinarySensorDeviceClass.SMOKE, device_class=BinarySensorDeviceClass.SMOKE, diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index a0c03581eee7b9..576d49296e90a2 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Xiaomi Bluetooth integration.""" from __future__ import annotations -import asyncio from collections.abc import Mapping import dataclasses from typing import Any @@ -96,7 +95,7 @@ async def async_step_bluetooth( self._discovery_info = await self._async_wait_for_full_advertisement( discovery_info, device ) - except asyncio.TimeoutError: + except TimeoutError: # This device might have a really long advertising interval # So create a config entry for it, and if we discover it has # encryption later, we can do a reauth @@ -220,7 +219,7 @@ async def async_step_user( self._discovery_info = await self._async_wait_for_full_advertisement( discovery.discovery_info, discovery.device ) - except asyncio.TimeoutError: + except TimeoutError: # This device might have a really long advertising interval # So create a config entry for it, and if we discover # it has encryption later, we can do a reauth diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index 1accfd9dc55282..5f9dea9eb4522a 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -19,14 +19,23 @@ XIAOMI_BLE_EVENT: Final = "xiaomi_ble_event" EVENT_CLASS_BUTTON: Final = "button" +EVENT_CLASS_DIMMER: Final = "dimmer" EVENT_CLASS_MOTION: Final = "motion" +EVENT_CLASS_CUBE: Final = "cube" BUTTON: Final = "button" +CUBE: Final = "cube" +DIMMER: Final = "dimmer" DOUBLE_BUTTON: Final = "double_button" TRIPPLE_BUTTON: Final = "tripple_button" +REMOTE: Final = "remote" +REMOTE_FAN: Final = "remote_fan" +REMOTE_VENFAN: Final = "remote_ventilator_fan" +REMOTE_BATHROOM: Final = "remote_bathroom" MOTION: Final = "motion" BUTTON_PRESS: Final = "button_press" +BUTTON_PRESS_LONG: Final = "button_press_long" BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long" DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long" TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long" diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 6d29af9ac11de7..8d281ddc8a94f3 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -24,16 +24,25 @@ BUTTON, BUTTON_PRESS, BUTTON_PRESS_DOUBLE_LONG, + BUTTON_PRESS_LONG, CONF_SUBTYPE, + CUBE, + DIMMER, DOMAIN, DOUBLE_BUTTON, DOUBLE_BUTTON_PRESS_DOUBLE_LONG, EVENT_CLASS, EVENT_CLASS_BUTTON, + EVENT_CLASS_CUBE, + EVENT_CLASS_DIMMER, EVENT_CLASS_MOTION, EVENT_TYPE, MOTION, MOTION_DEVICE, + REMOTE, + REMOTE_BATHROOM, + REMOTE_FAN, + REMOTE_VENFAN, TRIPPLE_BUTTON, TRIPPLE_BUTTON_PRESS_DOUBLE_LONG, XIAOMI_BLE_EVENT, @@ -41,14 +50,61 @@ TRIGGERS_BY_TYPE = { BUTTON_PRESS: ["press"], + BUTTON_PRESS_LONG: ["press", "long_press"], BUTTON_PRESS_DOUBLE_LONG: ["press", "double_press", "long_press"], + CUBE: ["rotate_left", "rotate_right"], + DIMMER: [ + "press", + "long_press", + "rotate_left", + "rotate_right", + "rotate_left_pressed", + "rotate_right_pressed", + ], MOTION_DEVICE: ["motion_detected"], } EVENT_TYPES = { BUTTON: ["button"], + CUBE: ["cube"], + DIMMER: ["dimmer"], DOUBLE_BUTTON: ["button_left", "button_right"], TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"], + REMOTE: [ + "button_on", + "button_off", + "button_brightness", + "button_plus", + "button_min", + "button_m", + ], + REMOTE_BATHROOM: [ + "button_heat", + "button_air_exchange", + "button_dry", + "button_fan", + "button_swing", + "button_decrease_speed", + "button_increase_speed", + "button_stop", + "button_light", + ], + REMOTE_FAN: [ + "button_fan", + "button_light", + "button_wind_speed", + "button_wind_mode", + "button_brightness", + "button_color_temperature", + ], + REMOTE_VENFAN: [ + "button_swing", + "button_power", + "button_timer_30_minutes", + "button_timer_60_minutes", + "button_increase_wind_speed", + "button_decrease_wind_speed", + ], MOTION: ["motion"], } @@ -78,11 +134,41 @@ class TriggerModelData: event_types=EVENT_TYPES[DOUBLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + CUBE: TriggerModelData( + event_class=EVENT_CLASS_CUBE, + event_types=EVENT_TYPES[CUBE], + triggers=TRIGGERS_BY_TYPE[CUBE], + ), + DIMMER: TriggerModelData( + event_class=EVENT_CLASS_DIMMER, + event_types=EVENT_TYPES[DIMMER], + triggers=TRIGGERS_BY_TYPE[DIMMER], + ), TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData( event_class=EVENT_CLASS_BUTTON, event_types=EVENT_TYPES[TRIPPLE_BUTTON], triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG], ), + REMOTE: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_BATHROOM: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_BATHROOM], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_FAN: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_FAN], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), + REMOTE_VENFAN: TriggerModelData( + event_class=EVENT_CLASS_BUTTON, + event_types=EVENT_TYPES[REMOTE_VENFAN], + triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_LONG], + ), MOTION_DEVICE: TriggerModelData( event_class=EVENT_CLASS_MOTION, event_types=EVENT_TYPES[MOTION], @@ -103,7 +189,13 @@ class TriggerModelData: "XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG], "K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG], + "YLYK01YL": TRIGGER_MODEL_DATA[REMOTE], + "YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN], + "YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN], + "YLYK01YL-BHFRC": TRIGGER_MODEL_DATA[REMOTE_BATHROOM], "MUE4094RT": TRIGGER_MODEL_DATA[MOTION_DEVICE], + "XMMF01JQD": TRIGGER_MODEL_DATA[CUBE], + "YLKG07YL/YLKG08YL": TRIGGER_MODEL_DATA[DIMMER], } diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py index 1d5b08fb8f9688..2c1550dc5d781c 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -18,6 +18,8 @@ from .const import ( DOMAIN, EVENT_CLASS_BUTTON, + EVENT_CLASS_CUBE, + EVENT_CLASS_DIMMER, EVENT_CLASS_MOTION, EVENT_PROPERTIES, EVENT_TYPE, @@ -36,10 +38,31 @@ ], device_class=EventDeviceClass.BUTTON, ), + EVENT_CLASS_CUBE: EventEntityDescription( + key=EVENT_CLASS_CUBE, + translation_key="cube", + event_types=[ + "rotate_left", + "rotate_right", + ], + ), + EVENT_CLASS_DIMMER: EventEntityDescription( + key=EVENT_CLASS_DIMMER, + translation_key="dimmer", + event_types=[ + "press", + "long_press", + "rotate_left", + "rotate_right", + "rotate_left_pressed", + "rotate_right_pressed", + ], + ), EVENT_CLASS_MOTION: EventEntityDescription( key=EVENT_CLASS_MOTION, translation_key="motion", event_types=["motion_detected"], + device_class=EventDeviceClass.MOTION, ), } diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index f11b2426f9661f..22629d3e326cea 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -23,6 +23,7 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", + "import_executor": true, "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.23.1"] + "requirements": ["xiaomi-ble==0.25.2"] } diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index c7cbe43bd9491b..d764a436f4c2b3 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -44,14 +44,43 @@ "press": "Press", "double_press": "Double Press", "long_press": "Long Press", - "motion_detected": "Motion Detected" + "motion_detected": "Motion Detected", + "rotate_left": "Rotate Left", + "rotate_right": "Rotate Right", + "rotate_left_pressed": "Rotate Left (Pressed)", + "rotate_right_pressed": "Rotate Right (Pressed)" }, "trigger_type": { "button": "Button \"{subtype}\"", "button_left": "Button Left \"{subtype}\"", "button_middle": "Button Middle \"{subtype}\"", "button_right": "Button Right \"{subtype}\"", - "motion": "{subtype}" + "button_on": "Button On \"{subtype}\"", + "button_off": "Button Off \"{subtype}\"", + "button_brightness": "Button Brightness \"{subtype}\"", + "button_plus": "Button Plus \"{subtype}\"", + "button_min": "Button Min \"{subtype}\"", + "button_m": "Button M \"{subtype}\"", + "button_heat": "Button Heat \"{subtype}\"", + "button_air_exchange": "Button Air Exchange \"{subtype}\"", + "button_dry": "Button Dry \"{subtype}\"", + "button_fan": "Button Fan \"{subtype}\"", + "button_swing": "Button Swing \"{subtype}\"", + "button_decrease_speed": "Button Decrease Speed \"{subtype}\"", + "button_increase_speed": "Button Inrease Speed \"{subtype}\"", + "button_stop": "Button Stop \"{subtype}\"", + "button_light": "Button Light \"{subtype}\"", + "button_wind_speed": "Button Wind Speed \"{subtype}\"", + "button_wind_mode": "Button Wind Mode \"{subtype}\"", + "button_color_temperature": "Button Color Temperature \"{subtype}\"", + "button_power": "Button Power \"{subtype}\"", + "button_timer_30_minutes": "Button Timer 30 Minutes \"{subtype}\"", + "button_timer_60_minutes": "Button Timer 30 Minutes \"{subtype}\"", + "button_increase_wind_speed": "Button Increase Wind Speed \"{subtype}\"", + "button_decrease_wind_speed": "Button Decrease Wind Speed \"{subtype}\"", + "dimmer": "{subtype}", + "motion": "{subtype}", + "cube": "{subtype}" } }, "entity": { @@ -67,6 +96,30 @@ } } }, + "cube": { + "state_attributes": { + "event_type": { + "state": { + "rotate_left": "Rotate left", + "rotate_right": "Rotate right" + } + } + } + }, + "dimmer": { + "state_attributes": { + "event_type": { + "state": { + "press": "Press", + "long_press": "Long press", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "rotate_left_pressed": "Rotate left (pressed)", + "rotate_right_pressed": "Rotate right (pressed)" + } + } + } + }, "motion": { "state_attributes": { "event_type": { diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 02e88c6b14ecdf..379db82042b252 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -382,9 +382,7 @@ async def async_step_connect( data[CONF_CLOUD_USERNAME] = self.cloud_username data[CONF_CLOUD_PASSWORD] = self.cloud_password data[CONF_CLOUD_COUNTRY] = self.cloud_country - if self.hass.config_entries.async_update_entry(existing_entry, data=data): - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_update_reload_and_abort(existing_entry, data=data) if self.name is None: self.name = self.model diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 830d8d9f69efe8..dac5a98d738c0e 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -64,7 +64,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, options=new_options) - entry.version = 2 + hass.config_entries.async_update_entry(entry, version=2) LOGGER.info("Migration to version %s successful", entry.version) diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index b5683777c24ad5..5082029af29bf8 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -1,8 +1,6 @@ """The Yale Access Bluetooth integration.""" from __future__ import annotations -import asyncio - from yalexs_ble import ( AuthError, ConnectionInfo, @@ -17,7 +15,7 @@ from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( @@ -75,6 +73,11 @@ def _async_shutdown(event: Event | None = None) -> None: # We may already have the advertisement, so check for it. if service_info := async_find_existing_service_info(hass, local_name, address): push_lock.update_advertisement(service_info.device, service_info.advertisement) + elif hass.state is CoreState.starting: + # If we are starting and the advertisement is not found, do not delay + # the setup. We will wait for the advertisement to be found and then + # discovery will trigger setup retry. + raise ConfigEntryNotReady("{local_name} ({address}) not advertising yet") entry.async_on_unload( bluetooth.async_register_callback( @@ -89,7 +92,7 @@ def _async_shutdown(event: Event | None = None) -> None: await push_lock.wait_for_first_update(DEVICE_TIMEOUT) except AuthError as ex: raise ConfigEntryAuthFailed(str(ex)) from ex - except (YaleXSBLEError, asyncio.TimeoutError) as ex: + except (YaleXSBLEError, TimeoutError) as ex: raise ConfigEntryNotReady( f"{ex}; Try moving the Bluetooth adapter closer to {local_name}" ) from ex diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 578519107cd8ac..ecfbd45f36efc7 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -24,7 +24,7 @@ ) from homeassistant.const import CONF_ADDRESS from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN @@ -113,13 +113,9 @@ async def async_step_integration_discovery( local_name_is_unique(lock_cfg.local_name) and entry.data.get(CONF_LOCAL_NAME) == lock_cfg.local_name ): - if hass.config_entries.async_update_entry( - entry, data={**entry.data, **new_data} - ): - hass.async_create_task( - hass.config_entries.async_reload(entry.entry_id) - ) - raise AbortFlow(reason="already_configured") + return self.async_update_reload_and_abort( + entry, data={**entry.data, **new_data}, reason="already_configured" + ) self._discovery_info = async_find_existing_service_info( hass, local_name, address diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index c9ed4bc6a8fad4..0cf142b63b5555 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.4.1"] + "requirements": ["yalexs-ble==2.4.2"] } diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 481678100dee72..ca4f8400022242 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -139,7 +139,7 @@ async def async_get_tts_audio(self, message, language, options): return (None, None) data = await request.read() - except (asyncio.TimeoutError, aiohttp.ClientError): + except (TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout for yandex speech kit API") return (None, None) diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index e7102f9c74bd5a..b0c8a8824741d3 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -64,7 +64,7 @@ async def _async_update_data(self) -> YardianDeviceState: async with asyncio.timeout(10): return await self.controller.fetch_device_state() - except asyncio.TimeoutError as e: + except TimeoutError as e: raise UpdateFailed("Communication with Device was time out") from e except NotAuthorizedException as e: raise UpdateFailed("Invalid access token") from e diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index cc9faa33194eef..f77e4d08dc92fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -1,7 +1,6 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from __future__ import annotations -import asyncio import logging import voluptuous as vol @@ -214,7 +213,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: device = await _async_get_device(hass, entry.data[CONF_HOST], entry) await _async_initialize(hass, entry, device) - except (asyncio.TimeoutError, OSError, BulbException) as ex: + except (TimeoutError, OSError, BulbException) as ex: raise ConfigEntryNotReady from ex found_unique_id = device.unique_id diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 23a2a13191359c..43f90511893e1b 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Yeelight integration.""" from __future__ import annotations -import asyncio import logging from urllib.parse import urlparse @@ -103,9 +102,7 @@ async def _async_handle_discovery_with_unique_id(self): ConfigEntryState.LOADED, ) if reload: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) return self.async_abort(reason="already_configured") return await self._async_handle_discovery() @@ -268,7 +265,7 @@ async def _async_try_connect(self, host, raise_on_progress=True): await bulb.async_listen(lambda _: True) await bulb.async_get_properties() await bulb.async_stop_listening() - except (asyncio.TimeoutError, yeelight.BulbException) as err: + except (TimeoutError, yeelight.BulbException) as err: _LOGGER.debug("Failed to get properties from %s: %s", host, err) raise CannotConnect from err _LOGGER.debug("Get properties: %s", bulb.last_properties) diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index 811a1904b04e91..bb5159c0b3b82d 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -1,7 +1,6 @@ """Support for Xiaomi Yeelight WiFi color bulb.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -176,7 +175,7 @@ async def _async_update_properties(self): self._available = True if not self._initialized: self._initialized = True - except asyncio.TimeoutError as ex: + except TimeoutError as ex: _LOGGER.debug( "timed out while trying to update device %s, %s: %s", self._host, diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index a9834823f5e4c2..abc17b8abd8b32 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -1,7 +1,6 @@ """Light platform support for yeelight.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine import logging import math @@ -255,7 +254,7 @@ async def _async_wrap( try: _LOGGER.debug("Calling %s with %s %s", func, args, kwargs) return await func(self, *args, **kwargs) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: # The wifi likely dropped, so we want to retry once since # python-yeelight will auto reconnect if attempts == 0: diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 43e976eeeacea7..8fa41bb92b1cc4 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -155,7 +155,7 @@ async def async_get_capabilities(self, host: str) -> CaseInsensitiveDict | None: for listener in self._listeners: listener.async_search((host, SSDP_TARGET[1])) - with contextlib.suppress(asyncio.TimeoutError): + with contextlib.suppress(TimeoutError): async with asyncio.timeout(DISCOVERY_TIMEOUT): await host_event.wait() diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index a1017a488d13be..270bd550038f36 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -130,7 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err - except (YoLinkClientError, asyncio.TimeoutError) as err: + except (YoLinkClientError, TimeoutError) as err: raise ConfigEntryNotReady from err device_coordinators = {} diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 6fd62ce571caf2..aae5be3f9d3d1a 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.6"] + "requirements": ["yolink-api==0.3.7"] } diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index b094846ee222be..f59231f2728437 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.3.5"] + "requirements": ["zamg==0.3.6"] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 2e058c4067c321..344c174242a598 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -1,7 +1,6 @@ """Support for exposing Home Assistant via Zeroconf.""" from __future__ import annotations -import asyncio import contextlib from contextlib import suppress from dataclasses import dataclass @@ -215,9 +214,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) - zeroconf_types, homekit_models = await asyncio.gather( - async_get_zeroconf(hass), async_get_homekit(hass) - ) + zeroconf_types = await async_get_zeroconf(hass) + homekit_models = await async_get_homekit(hass) homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( homekit_models ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index aecc88968f301a..f7ca2eeeed04c0 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@bdraco"], "dependencies": ["network", "api"], "documentation": "https://www.home-assistant.io/integrations/zeroconf", + "import_executor": true, "integration_type": "system", "iot_class": "local_push", "loggers": ["zeroconf"], diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1eb3369c1bef9c..34ba0d33482358 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -3,7 +3,6 @@ import contextlib import copy import logging -import os import re import voluptuous as vol @@ -18,7 +17,6 @@ from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from . import repairs, websocket_api @@ -129,15 +127,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) ) - # temporary code to remove the ZHA storage file from disk. - # this will be removed in 2022.10.0 - storage_path = hass.config.path(STORAGE_DIR, "zha.storage") - if os.path.isfile(storage_path): - _LOGGER.debug("removing ZHA storage file") - await hass.async_add_executor_job(os.remove, storage_path) - else: - _LOGGER.debug("ZHA storage file does not exist or was already removed") - # Load and cache device trigger information early device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) @@ -272,8 +261,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: data[CONF_DEVICE][CONF_BAUDRATE] = baudrate - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=2) if config_entry.version == 2: data = {**config_entry.data} @@ -281,8 +269,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if data[CONF_RADIO_TYPE] == "ti_cc": data[CONF_RADIO_TYPE] = "znp" - config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=3) if config_entry.version == 3: data = {**config_entry.data} @@ -299,8 +286,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if not data[CONF_DEVICE].get(CONF_FLOW_CONTROL): data[CONF_DEVICE][CONF_FLOW_CONTROL] = None - config_entry.version = 4 - hass.config_entries.async_update_entry(config_entry, data=data) + hass.config_entries.async_update_entry(config_entry, data=data, version=4) _LOGGER.info("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 5ec829fcb05787..aed0a16a681397 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -4,6 +4,7 @@ import functools from typing import Any +from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata import zigpy.types as t from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone @@ -26,6 +27,7 @@ CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -76,8 +78,16 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Initialize the ZHA binary sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata + self._attribute_name = binary_sensor_metadata.attribute_name async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index e16ae082eda4da..2c0028cd3d1921 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -1,11 +1,16 @@ """Support for ZHA button.""" from __future__ import annotations -import abc import functools import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.quirks.v2 import ( + EntityMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, +) + from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform @@ -14,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, QUIRK_METADATA, SIGNAL_ADD_ENTITIES from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -58,6 +63,8 @@ class ZHAButton(ZhaEntity, ButtonEntity): """Defines a ZHA button.""" _command_name: str + _args: list[Any] + _kwargs: dict[str, Any] def __init__( self, @@ -67,18 +74,33 @@ def __init__( **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata + self._command_name = button_metadata.command_name + self._args = button_metadata.args + self._kwargs = button_metadata.kwargs - @abc.abstractmethod def get_args(self) -> list[Any]: """Return the arguments to use in the command.""" + return list(self._args) if self._args else [] + + def get_kwargs(self) -> dict[str, Any]: + """Return the keyword arguments to use in the command.""" + return self._kwargs async def async_press(self) -> None: """Send out a update command.""" command = getattr(self._cluster_handler, self._command_name) - arguments = self.get_args() - await command(*arguments) + arguments = self.get_args() or [] + kwargs = self.get_kwargs() or {} + await command(*arguments, **kwargs) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_IDENTIFY) @@ -106,11 +128,8 @@ def create_entity( _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC _command_name = "identify" - - def get_args(self) -> list[Any]: - """Return the arguments to use in the command.""" - - return [DEFAULT_DURATION] + _kwargs = {} + _args = [DEFAULT_DURATION] class ZHAAttributeButton(ZhaEntity, ButtonEntity): @@ -127,8 +146,17 @@ def __init__( **kwargs: Any, ) -> None: """Init this button.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata + self._attribute_name = button_metadata.attribute_name + self._attribute_value = button_metadata.attribute_value async def async_press(self) -> None: """Write attribute with defined value.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index 6c65a993e957bf..1f7485d4922d55 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -1,7 +1,6 @@ """Cluster handlers module for Zigbee Home Automation.""" from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterator import contextlib from enum import Enum @@ -62,7 +61,7 @@ def wrap_zigpy_exceptions() -> Iterator[None]: """Wrap zigpy exceptions in `HomeAssistantError` exceptions.""" try: yield - except asyncio.TimeoutError as exc: + except TimeoutError as exc: raise HomeAssistantError( "Failed to send request: device did not respond" ) from exc @@ -214,7 +213,7 @@ async def bind(self): }, }, ) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, @@ -275,7 +274,7 @@ async def configure_reporting(self) -> None: try: res = await self.cluster.configure_reporting_multiple(reports, **kwargs) self._configure_reporting_status(reports, res[0], event_data) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "failed to set reporting on '%s' cluster for: %s", self.cluster.ep_attribute, @@ -518,7 +517,7 @@ async def _get_attributes( manufacturer=manufacturer, ) result.update(read) - except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + except (TimeoutError, zigpy.exceptions.ZigbeeException) as ex: self.debug( "failed to get attributes '%s' on '%s' cluster: %s", chunk, @@ -628,8 +627,9 @@ class ClientClusterHandler(ClusterHandler): """ClusterHandler for Zigbee client (output) clusters.""" @callback - def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + def attribute_updated(self, attrid: int, value: Any, timestamp: Any) -> None: """Handle an attribute updated on this cluster.""" + super().attribute_updated(attrid, value, timestamp) try: attr_name = self._cluster.attributes[attrid].name diff --git a/homeassistant/components/zha/core/cluster_handlers/general.py b/homeassistant/components/zha/core/cluster_handlers/general.py index 14401b260b2bde..d2927f6d028116 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -56,7 +56,6 @@ SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, - UNKNOWN as ZHA_UNKNOWN, ) from . import ( AttrReportConfig, @@ -538,14 +537,9 @@ class OtaClusterHandler(ClusterHandler): } @property - def current_file_version(self) -> str: + def current_file_version(self) -> int | None: """Return cached value of current_file_version attribute.""" - current_file_version = self.cluster.get( - Ota.AttributeDefs.current_file_version.name - ) - if current_file_version is not None: - return f"0x{int(current_file_version):08x}" - return ZHA_UNKNOWN + return self.cluster.get(Ota.AttributeDefs.current_file_version.name) @registries.CLIENT_CLUSTER_HANDLER_REGISTRY.register(Ota.cluster_id) @@ -559,36 +553,31 @@ class OtaClientClusterHandler(ClientClusterHandler): } @property - def current_file_version(self) -> str: + def current_file_version(self) -> int | None: """Return cached value of current_file_version attribute.""" - current_file_version = self.cluster.get( - Ota.AttributeDefs.current_file_version.name - ) - if current_file_version is not None: - return f"0x{int(current_file_version):08x}" - return ZHA_UNKNOWN + return self.cluster.get(Ota.AttributeDefs.current_file_version.name) @callback def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None ) -> None: """Handle OTA commands.""" - if command_id in self.cluster.server_commands: - cmd_name = self.cluster.server_commands[command_id].name - else: - cmd_name = command_id + if command_id not in self.cluster.server_commands: + return signal_id = self._endpoint.unique_id.split("-")[0] + cmd_name = self.cluster.server_commands[command_id].name + if cmd_name == Ota.ServerCommandDefs.query_next_image.name: assert args - self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) - async def async_check_for_update(self): - """Check for firmware availability by issuing an image notify command.""" - await self.cluster.image_notify( - payload_type=(self.cluster.ImageNotifyCommand.PayloadType.QueryJitter), - query_jitter=100, - ) + current_file_version = args[3] + self.cluster.update_attribute( + Ota.AttributeDefs.current_file_version.id, current_file_version + ) + self.async_send_signal( + SIGNAL_UPDATE_DEVICE.format(signal_id), current_file_version + ) @registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register(Partition.cluster_id) diff --git a/homeassistant/components/zha/core/cluster_handlers/lightlink.py b/homeassistant/components/zha/core/cluster_handlers/lightlink.py index e2ed36bdc83492..85ec69050697ab 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lightlink.py +++ b/homeassistant/components/zha/core/cluster_handlers/lightlink.py @@ -1,5 +1,4 @@ """Lightlink cluster handlers module for Zigbee Home Automation.""" -import asyncio import zigpy.exceptions from zigpy.zcl.clusters.lightlink import LightLink @@ -32,7 +31,7 @@ async def async_configure(self) -> None: try: rsp = await self.cluster.get_group_identifiers(0) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as exc: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as exc: self.warning("Couldn't get list of groups: %s", str(exc)) return diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index cb0aa46604686f..fd54351739eb6b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,6 +64,8 @@ BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] BINDINGS = "bindings" +CLUSTER_DETAILS = "cluster_details" + CLUSTER_HANDLER_ACCELEROMETER = "accelerometer" CLUSTER_HANDLER_BINARY_INPUT = "binary_input" CLUSTER_HANDLER_ANALOG_INPUT = "analog_input" @@ -230,6 +232,10 @@ PRESET_COMPLEX = "Complex" PRESET_TEMP_MANUAL = "Temporary manual" +QUIRK_METADATA = "quirk_metadata" + +ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS" + ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_OPTIONS = "zha_options" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index dd5a39115ae5e1..f1b7ec60728c4a 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -15,6 +15,7 @@ import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks +from zigpy.quirks.v2 import CustomDeviceV2 from zigpy.types.named import EUI64, NWK from zigpy.zcl.clusters import Cluster from zigpy.zcl.clusters.general import Groups, Identify @@ -33,7 +34,7 @@ ) from homeassistant.helpers.event import async_track_time_interval -from . import const +from . import const, discovery from .cluster_handlers import ClusterHandler, ZDOClusterHandler from .const import ( ATTR_ACTIVE_COORDINATOR, @@ -432,6 +433,7 @@ def new( zha_dev.async_update_sw_build_id, ) ) + discovery.PROBE.discover_device_entities(zha_dev) return zha_dev @callback @@ -581,6 +583,9 @@ async def async_configure(self) -> None: await asyncio.gather( *(endpoint.async_configure() for endpoint in self._endpoints.values()) ) + if isinstance(self._zigpy_device, CustomDeviceV2): + self.debug("applying quirks v2 custom device configuration") + await self._zigpy_device.apply_custom_configuration() async_dispatcher_send( self.hass, const.ZHA_CLUSTER_HANDLER_MSG, @@ -870,7 +875,7 @@ async def async_add_to_group(self, group_id: int) -> None: # store it, so we cannot rely on it existing after being written. This is # only done to make the ZCL command valid. await self._zigpy_device.add_to_group(group_id, name=f"0x{group_id:04X}") - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to add device '%s' to group: 0x%04x ex: %s", self._zigpy_device.ieee, @@ -882,7 +887,7 @@ async def async_remove_from_group(self, group_id: int) -> None: """Remove this device from the provided zigbee group.""" try: await self._zigpy_device.remove_from_group(group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to remove device '%s' from group: 0x%04x ex: %s", self._zigpy_device.ieee, @@ -898,7 +903,7 @@ async def async_add_endpoint_to_group( await self._zigpy_device.endpoints[endpoint_id].add_to_group( group_id, name=f"0x{group_id:04X}" ) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( "Failed to add endpoint: %s for device: '%s' to group: 0x%04x ex: %s", endpoint_id, @@ -913,7 +918,7 @@ async def async_remove_endpoint_from_group( """Remove the device endpoint from the provided zigbee group.""" try: await self._zigpy_device.endpoints[endpoint_id].remove_from_group(group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( ( "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 1fed2caab60eac..221c601827ef92 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,8 +4,22 @@ from collections import Counter from collections.abc import Callable import logging -from typing import TYPE_CHECKING, cast - +from typing import TYPE_CHECKING, Any, cast + +from slugify import slugify +from zigpy.quirks.v2 import ( + BinarySensorMetadata, + CustomDeviceV2, + EntityType, + NumberMetadata, + SwitchMetadata, + WriteAttributeButtonMetadata, + ZCLCommandButtonMetadata, + ZCLEnumMetadata, + ZCLSensorMetadata, +) +from zigpy.state import State +from zigpy.zcl import ClusterType from zigpy.zcl.clusters.general import Ota from homeassistant.const import CONF_TYPE, Platform @@ -64,6 +78,59 @@ _LOGGER = logging.getLogger(__name__) +QUIRKS_ENTITY_META_TO_ENTITY_CLASS = { + ( + Platform.BUTTON, + WriteAttributeButtonMetadata, + EntityType.CONFIG, + ): button.ZHAAttributeButton, + (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton, + ( + Platform.BUTTON, + ZCLCommandButtonMetadata, + EntityType.DIAGNOSTIC, + ): button.ZHAButton, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.CONFIG, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.DIAGNOSTIC, + ): binary_sensor.BinarySensor, + ( + Platform.BINARY_SENSOR, + BinarySensorMetadata, + EntityType.STANDARD, + ): binary_sensor.BinarySensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.DIAGNOSTIC): sensor.EnumSensor, + (Platform.SENSOR, ZCLEnumMetadata, EntityType.STANDARD): sensor.EnumSensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor, + (Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor, + (Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity, + ( + Platform.SELECT, + ZCLEnumMetadata, + EntityType.DIAGNOSTIC, + ): select.ZCLEnumSelectEntity, + ( + Platform.NUMBER, + NumberMetadata, + EntityType.CONFIG, + ): number.ZHANumberConfigurationEntity, + (Platform.NUMBER, NumberMetadata, EntityType.DIAGNOSTIC): number.ZhaNumber, + (Platform.NUMBER, NumberMetadata, EntityType.STANDARD): number.ZhaNumber, + ( + Platform.SWITCH, + SwitchMetadata, + EntityType.CONFIG, + ): switch.ZHASwitchConfigurationEntity, + (Platform.SWITCH, SwitchMetadata, EntityType.STANDARD): switch.Switch, +} + + @callback async def async_add_entities( _async_add_entities: AddEntitiesCallback, @@ -71,13 +138,19 @@ async def async_add_entities( tuple[ type[ZhaEntity], tuple[str, ZHADevice, list[ClusterHandler]], + dict[str, Any], ] ], + **kwargs, ) -> None: """Add entities helper.""" if not entities: return - to_add = [ent_cls.create_entity(*args) for ent_cls, args in entities] + + to_add = [ + ent_cls.create_entity(*args, **{**kwargs, **kw_args}) + for ent_cls, args, kw_args in entities + ] entities_to_add = [entity for entity in to_add if entity is not None] _async_add_entities(entities_to_add, update_before_add=False) entities.clear() @@ -104,6 +177,181 @@ def discover_entities(self, endpoint: Endpoint) -> None: self.discover_multi_entities(endpoint, config_diagnostic_entities=True) zha_regs.ZHA_ENTITIES.clean_up() + @callback + def discover_device_entities(self, device: ZHADevice) -> None: + """Discover entities for a ZHA device.""" + _LOGGER.debug( + "Discovering entities for device: %s-%s", + str(device.ieee), + device.name, + ) + + if device.is_coordinator: + self.discover_coordinator_device_entities(device) + return + + self.discover_quirks_v2_entities(device) + zha_regs.ZHA_ENTITIES.clean_up() + + @callback + def discover_quirks_v2_entities(self, device: ZHADevice) -> None: + """Discover entities for a ZHA device exposed by quirks v2.""" + _LOGGER.debug( + "Attempting to discover quirks v2 entities for device: %s-%s", + str(device.ieee), + device.name, + ) + + if not isinstance(device.device, CustomDeviceV2): + _LOGGER.debug( + "Device: %s-%s is not a quirks v2 device - skipping " + "discover_quirks_v2_entities", + str(device.ieee), + device.name, + ) + return + + zigpy_device: CustomDeviceV2 = device.device + + if not zigpy_device.exposes_metadata: + _LOGGER.debug( + "Device: %s-%s does not expose any quirks v2 entities", + str(device.ieee), + device.name, + ) + return + + for ( + cluster_details, + quirk_metadata_list, + ) in zigpy_device.exposes_metadata.items(): + endpoint_id, cluster_id, cluster_type = cluster_details + + if endpoint_id not in device.endpoints: + _LOGGER.warning( + "Device: %s-%s does not have an endpoint with id: %s - unable to " + "create entity with cluster details: %s", + str(device.ieee), + device.name, + endpoint_id, + cluster_details, + ) + continue + + endpoint: Endpoint = device.endpoints[endpoint_id] + cluster = ( + endpoint.zigpy_endpoint.in_clusters.get(cluster_id) + if cluster_type is ClusterType.Server + else endpoint.zigpy_endpoint.out_clusters.get(cluster_id) + ) + + if cluster is None: + _LOGGER.warning( + "Device: %s-%s does not have a cluster with id: %s - " + "unable to create entity with cluster details: %s", + str(device.ieee), + device.name, + cluster_id, + cluster_details, + ) + continue + + cluster_handler_id = f"{endpoint.id}:0x{cluster.cluster_id:04x}" + cluster_handler = ( + endpoint.all_cluster_handlers.get(cluster_handler_id) + if cluster_type is ClusterType.Server + else endpoint.client_cluster_handlers.get(cluster_handler_id) + ) + assert cluster_handler + + for quirk_metadata in quirk_metadata_list: + platform = Platform(quirk_metadata.entity_platform.value) + metadata_type = type(quirk_metadata.entity_metadata) + entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get( + (platform, metadata_type, quirk_metadata.entity_type) + ) + + if entity_class is None: + _LOGGER.warning( + "Device: %s-%s has an entity with details: %s that does not" + " have an entity class mapping - unable to create entity", + str(device.ieee), + device.name, + { + zha_const.CLUSTER_DETAILS: cluster_details, + zha_const.QUIRK_METADATA: quirk_metadata, + }, + ) + continue + + # automatically add the attribute to ZCL_INIT_ATTRS for the cluster + # handler if it is not already in the list + if ( + hasattr(quirk_metadata.entity_metadata, "attribute_name") + and quirk_metadata.entity_metadata.attribute_name + not in cluster_handler.ZCL_INIT_ATTRS + ): + init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy() + init_attrs[ + quirk_metadata.entity_metadata.attribute_name + ] = quirk_metadata.attribute_initialized_from_cache + cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs + + endpoint.async_new_entity( + platform, + entity_class, + endpoint.unique_id, + [cluster_handler], + quirk_metadata=quirk_metadata, + ) + + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + platform, + entity_class.__name__, + [cluster_handler.name], + ) + + @callback + def discover_coordinator_device_entities(self, device: ZHADevice) -> None: + """Discover entities for the coordinator device.""" + _LOGGER.debug( + "Discovering entities for coordinator device: %s-%s", + str(device.ieee), + device.name, + ) + state: State = device.gateway.application_controller.state + platforms: dict[Platform, list] = get_zha_data(device.hass).platforms + + @callback + def process_counters(counter_groups: str) -> None: + for counter_group, counters in getattr(state, counter_groups).items(): + for counter in counters: + platforms[Platform.SENSOR].append( + ( + sensor.DeviceCounterSensor, + ( + f"{slugify(str(device.ieee))}_{counter_groups}_{counter_group}_{counter}", + device, + counter_groups, + counter_group, + counter, + ), + {}, + ) + ) + _LOGGER.debug( + "'%s' platform -> '%s' using %s", + Platform.SENSOR, + sensor.DeviceCounterSensor.__name__, + f"counter groups[{counter_groups}] counter group[{counter_group}] counter[{counter}]", + ) + + process_counters("counters") + process_counters("broadcast_counters") + process_counters("device_counters") + process_counters("group_counters") + @callback def discover_by_device_type(self, endpoint: Endpoint) -> None: """Process an endpoint on a zigpy device.""" @@ -260,7 +508,7 @@ def discover_multi_entities( for platform, ent_n_handler_list in matches.items(): for entity_and_handler in ent_n_handler_list: _LOGGER.debug( - "'%s' component -> '%s' using %s", + "'%s' platform -> '%s' using %s", platform, entity_and_handler.entity_class.__name__, [ch.name for ch in entity_and_handler.claimed_cluster_handlers], @@ -268,7 +516,8 @@ def discover_multi_entities( for platform, ent_n_handler_list in matches.items(): for entity_and_handler in ent_n_handler_list: if platform == cmpt_by_dev_type: - # for well known device types, like thermostats we'll take only 1st class + # for well known device types, + # like thermostats we'll take only 1st class endpoint.async_new_entity( platform, entity_and_handler.entity_class, @@ -356,6 +605,7 @@ def discover_group_entities(self, group: ZHAGroup) -> None: group.group_id, zha_gateway.coordinator_zha_device, ), + {}, ) ) async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index 490a4e05ea2e24..37a2c951a7f973 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -7,8 +7,6 @@ import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -from zigpy.typing import EndpointType as ZigpyEndpointType - from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -19,6 +17,8 @@ from .helpers import get_zha_data if TYPE_CHECKING: + from zigpy import Endpoint as ZigpyEndpoint + from .cluster_handlers import ClientClusterHandler from .device import ZHADevice @@ -34,11 +34,11 @@ class Endpoint: """Endpoint for a zha device.""" - def __init__(self, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> None: + def __init__(self, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> None: """Initialize instance.""" assert zigpy_endpoint is not None assert device is not None - self._zigpy_endpoint: ZigpyEndpointType = zigpy_endpoint + self._zigpy_endpoint: ZigpyEndpoint = zigpy_endpoint self._device: ZHADevice = device self._all_cluster_handlers: dict[str, ClusterHandler] = {} self._claimed_cluster_handlers: dict[str, ClusterHandler] = {} @@ -66,7 +66,7 @@ def client_cluster_handlers(self) -> dict[str, ClientClusterHandler]: return self._client_cluster_handlers @property - def zigpy_endpoint(self) -> ZigpyEndpointType: + def zigpy_endpoint(self) -> ZigpyEndpoint: """Return endpoint of zigpy device.""" return self._zigpy_endpoint @@ -104,7 +104,7 @@ def zigbee_signature(self) -> tuple[int, dict[str, Any]]: ) @classmethod - def new(cls, zigpy_endpoint: ZigpyEndpointType, device: ZHADevice) -> Endpoint: + def new(cls, zigpy_endpoint: ZigpyEndpoint, device: ZHADevice) -> Endpoint: """Create new endpoint and populate cluster handlers.""" endpoint = cls(zigpy_endpoint, device) endpoint.add_all_cluster_handlers() @@ -211,6 +211,7 @@ def async_new_entity( entity_class: CALLABLE_T, unique_id: str, cluster_handlers: list[ClusterHandler], + **kwargs: Any, ) -> None: """Create a new entity.""" from .device import DeviceStatus # pylint: disable=import-outside-toplevel @@ -220,7 +221,7 @@ def async_new_entity( zha_data = get_zha_data(self.device.hass) zha_data.platforms[platform].append( - (entity_class, (unique_id, self.device, cluster_handlers)) + (entity_class, (unique_id, self.device, cluster_handlers), kwargs or {}) ) @callback diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 519668052e0ede..a62c00e7106874 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -87,6 +87,9 @@ def associated_entities(self) -> list[dict[str, Any]]: entity_info = [] for entity_ref in zha_device_registry.get(self.device.ieee): + # We have device entities now that don't leverage cluster handlers + if not entity_ref.cluster_handlers: + continue entity = entity_registry.async_get(entity_ref.reference_id) handler = list(entity_ref.cluster_handlers.values())[0] @@ -112,7 +115,7 @@ async def async_remove_from_group(self) -> None: await self._zha_device.device.endpoints[ self._endpoint_id ].remove_from_group(self._zha_group.group_id) - except (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError) as ex: + except (zigpy.exceptions.ZigbeeException, TimeoutError) as ex: self.debug( ( "Failed to remove endpoint: %s for device '%s' from group: 0x%04x" diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index ae68e6d5ccad6d..57088818c66920 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -28,6 +28,7 @@ UNKNOWN, ) from .core.device import ZHADevice +from .core.gateway import ZHAGateway from .core.helpers import async_get_zha_device, get_zha_data, get_zha_gateway KEYS_TO_REDACT = { @@ -63,7 +64,8 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" zha_data = get_zha_data(hass) - app = get_zha_gateway(hass).application_controller + gateway: ZHAGateway = get_zha_gateway(hass) + app = gateway.application_controller energy_scan = await app.energy_scan( channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 @@ -86,6 +88,14 @@ async def async_get_config_entry_diagnostics( "zigpy_zigate": version("zigpy-zigate"), "zhaquirks": version("zha-quirks"), }, + "devices": [ + { + "manufacturer": device.manufacturer, + "model": device.model, + "logical_type": device.device_type, + } + for device in gateway.devices.values() + ], }, KEYS_TO_REDACT, ) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index b92d077907fd49..3f127c74c0e75b 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -7,7 +7,9 @@ import logging from typing import TYPE_CHECKING, Any, Self -from homeassistant.const import ATTR_NAME +from zigpy.quirks.v2 import EntityMetadata, EntityType + +from homeassistant.const import ATTR_NAME, EntityCategory from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import entity from homeassistant.helpers.debounce import Debouncer @@ -175,6 +177,31 @@ def create_entity( """ return cls(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + if entity_metadata.initially_disabled: + self._attr_entity_registry_enabled_default = False + + if entity_metadata.translation_key: + self._attr_translation_key = entity_metadata.translation_key + + if hasattr(entity_metadata.entity_metadata, "attribute_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.attribute_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name + elif hasattr(entity_metadata.entity_metadata, "command_name"): + if not entity_metadata.translation_key: + self._attr_translation_key = ( + entity_metadata.entity_metadata.command_name + ) + self._unique_id_suffix = entity_metadata.entity_metadata.command_name + if entity_metadata.entity_type is EntityType.CONFIG: + self._attr_entity_category = EntityCategory.CONFIG + elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + @property def available(self) -> bool: """Return entity availability.""" @@ -324,7 +351,7 @@ def async_state_changed_listener( """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group assert self._change_listener_debouncer - self.hass.create_task(self._change_listener_debouncer.async_call()) + self._change_listener_debouncer.async_schedule_call() async def async_will_remove_from_hass(self) -> None: """Handle removal from Home Assistant.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 84399f3da32f51..aa117c7ef9b1c0 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1185,7 +1185,7 @@ def __init__( self._zha_config_enhanced_light_transition = False self._attr_color_mode = ColorMode.UNKNOWN - self._attr_supported_color_modes = set() + self._attr_supported_color_modes = {ColorMode.ONOFF} # remove this when all ZHA platforms and base entities are updated @property @@ -1285,6 +1285,19 @@ async def async_update(self) -> None: effects_count = Counter(itertools.chain(all_effects)) self._attr_effect = effects_count.most_common(1)[0][0] + supported_color_modes = {ColorMode.ONOFF} + all_supported_color_modes: list[set[ColorMode]] = list( + helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + supported_color_modes = filter_supported_color_modes( + set().union(*all_supported_color_modes) + ) + + self._attr_supported_color_modes = supported_color_modes + + self._attr_color_mode = ColorMode.UNKNOWN all_color_modes = list( helpers.find_state_attributes(on_states, light.ATTR_COLOR_MODE) ) @@ -1292,25 +1305,26 @@ async def async_update(self) -> None: # Report the most common color mode, select brightness and onoff last color_mode_count = Counter(itertools.chain(all_color_modes)) if ColorMode.ONOFF in color_mode_count: - color_mode_count[ColorMode.ONOFF] = -1 + if ColorMode.ONOFF in supported_color_modes: + color_mode_count[ColorMode.ONOFF] = -1 + else: + color_mode_count.pop(ColorMode.ONOFF) if ColorMode.BRIGHTNESS in color_mode_count: - color_mode_count[ColorMode.BRIGHTNESS] = 0 - self._attr_color_mode = color_mode_count.most_common(1)[0][0] + if ColorMode.BRIGHTNESS in supported_color_modes: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + else: + color_mode_count.pop(ColorMode.BRIGHTNESS) + if color_mode_count: + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + else: + self._attr_color_mode = next(iter(supported_color_modes)) + if self._attr_color_mode == ColorMode.HS and ( color_mode_count[ColorMode.HS] != len(self._group.members) or self._zha_config_always_prefer_xy_color_mode ): # switch to XY if all members do not support HS self._attr_color_mode = ColorMode.XY - all_supported_color_modes: list[set[ColorMode]] = list( - helpers.find_state_attributes(states, light.ATTR_SUPPORTED_COLOR_MODES) - ) - if all_supported_color_modes: - # Merge all color modes. - self._attr_supported_color_modes = filter_supported_color_modes( - set().union(*all_supported_color_modes) - ) - self._attr_supported_features = LightEntityFeature(0) for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index a82b1f87103ba6..ce9c1f1227b256 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -30,7 +30,7 @@ def async_describe_zha_event(event: Event) -> dict[str, str]: device: dr.DeviceEntry | None = None device_name: str = "Unknown device" zha_device: ZHADevice | None = None - event_data: dict = event.data + event_data = event.data event_type: str | None = None event_subtype: str | None = None diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index e9ab98fa6bfef4..fc050c9b2d12a1 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,6 +6,7 @@ "config_flow": true, "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/zha", + "import_executor": true, "iot_class": "local_polling", "loggers": [ "aiosqlite", @@ -21,12 +22,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.0", + "bellows==0.38.1", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.111", - "zigpy-deconz==0.23.0", - "zigpy==0.62.3", + "zha-quirks==0.0.112", + "zigpy-deconz==0.23.1", + "zigpy==0.63.4", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", @@ -40,6 +41,12 @@ "description": "*2652*", "known_devices": ["slae.sh cc2652rb stick"] }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*slzb-07*", + "known_devices": ["smlight slzb-07"] + }, { "vid": "1A86", "pid": "55D4", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 2b6a64edf69eea..c452752f14bd96 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING, Any, Self +from zigpy.quirks.v2 import EntityMetadata, NumberMetadata from zigpy.zcl.clusters.hvac import Thermostat from homeassistant.components.number import NumberEntity, NumberMode @@ -24,6 +25,7 @@ CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -400,7 +402,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -423,8 +425,27 @@ def __init__( ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + number_metadata: NumberMetadata = entity_metadata.entity_metadata + self._attribute_name = number_metadata.attribute_name + + if number_metadata.min is not None: + self._attr_native_min_value = number_metadata.min + if number_metadata.max is not None: + self._attr_native_max_value = number_metadata.max + if number_metadata.step is not None: + self._attr_native_step = number_metadata.step + if number_metadata.unit is not None: + self._attr_native_unit_of_measurement = number_metadata.unit + if number_metadata.multiplier is not None: + self._attr_multiplier = number_metadata.multiplier + @property def native_value(self) -> float: """Return the current value.""" @@ -953,7 +974,10 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[0] -@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) # pylint: disable-next=hass-invalid-inheritance # needs fixing class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): """Local temperature calibration.""" @@ -971,6 +995,20 @@ class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[0] +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, + models={"TRVZB"}, + stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, +) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class SonoffThermostatLocalTempCalibration(ThermostatLocalTempCalibration): + """Local temperature calibration for the Sonoff TRVZB.""" + + _attr_native_min_value: float = -7 + _attr_native_max_value: float = 7 + _attr_native_step: float = 0.2 + + @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY, models={"SNZB-06P"} ) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 3736858d599e50..53acc5cdd029b3 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -10,6 +10,7 @@ from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -27,6 +28,7 @@ CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, @@ -82,9 +84,9 @@ def __init__( **kwargs: Any, ) -> None: """Init this select entity.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] self._attribute_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._cluster_handler: ClusterHandler = cluster_handlers[0] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @property @@ -176,7 +178,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -198,10 +200,19 @@ def __init__( **kwargs: Any, ) -> None: """Init this select entity.""" - self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = zcl_enum_metadata.attribute_name + self._enum = zcl_enum_metadata.enum + @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 929ac803b1008c..6a68b55a8beec4 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,15 +1,19 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +import asyncio from dataclasses import dataclass from datetime import timedelta import enum import functools +import logging import numbers import random from typing import TYPE_CHECKING, Any, Self from zigpy import types +from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata, ZCLSensorMetadata +from zigpy.state import Counter, State from zigpy.zcl.clusters.closures import WindowCovering from zigpy.zcl.clusters.general import Basic @@ -66,12 +70,13 @@ CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, DATA_ZHA, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) from .core.helpers import get_zha_data from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES -from .entity import ZhaEntity +from .entity import BaseZhaEntity, ZhaEntity if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler @@ -93,6 +98,8 @@ 255: "Unknown", } +_LOGGER = logging.getLogger(__name__) + CLUSTER_HANDLER_ST_HUMIDITY_CLUSTER = ( f"cluster_handler_0x{SMARTTHINGS_HUMIDITY_CLUSTER:04x}" ) @@ -133,17 +140,6 @@ class Sensor(ZhaEntity, SensorEntity): _divisor: int = 1 _multiplier: int | float = 1 - def __init__( - self, - unique_id: str, - zha_device: ZHADevice, - cluster_handlers: list[ClusterHandler], - **kwargs: Any, - ) -> None: - """Init this sensor.""" - super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) - self._cluster_handler: ClusterHandler = cluster_handlers[0] - @classmethod def create_entity( cls, @@ -157,14 +153,44 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name ): + _LOGGER.debug( + "%s is not supported - skipping %s entity creation", + cls._attribute_name, + cls.__name__, + ) return None return cls(unique_id, zha_device, cluster_handlers, **kwargs) + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + if sensor_metadata.divisor is not None: + self._divisor = sensor_metadata.divisor + if sensor_metadata.multiplier is not None: + self._multiplier = sensor_metadata.multiplier + if sensor_metadata.unit is not None: + self._attr_native_unit_of_measurement = sensor_metadata.unit + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() @@ -244,6 +270,83 @@ async def _refresh(self, time): ) +class DeviceCounterSensor(BaseZhaEntity, SensorEntity): + """Device counter sensor.""" + + _attr_should_poll = True + _attr_state_class: SensorStateClass = SensorStateClass.TOTAL + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + + @classmethod + def create_entity( + cls, + unique_id: str, + zha_device: ZHADevice, + counter_groups: str, + counter_group: str, + counter: str, + **kwargs: Any, + ) -> Self | None: + """Entity Factory. + + Return entity if it is a supported configuration, otherwise return None + """ + return cls( + unique_id, zha_device, counter_groups, counter_group, counter, **kwargs + ) + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + counter_groups: str, + counter_group: str, + counter: str, + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, **kwargs) + state: State = self._zha_device.gateway.application_controller.state + self._zigpy_counter: Counter = ( + getattr(state, counter_groups).get(counter_group, {}).get(counter, None) + ) + self._attr_name: str = self._zigpy_counter.name + self.remove_future: asyncio.Future + + @property + def available(self) -> bool: + """Return entity availability.""" + return self._zha_device.available + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + self.remove_future = self.hass.loop.create_future() + self._zha_device.gateway.register_entity_reference( + self._zha_device.ieee, + self.entity_id, + self._zha_device, + {}, + self.device_info, + self.remove_future, + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + await super().async_will_remove_from_hass() + self.zha_device.gateway.remove_entity_reference(self) + self.remove_future.set_result(True) + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + return self._zigpy_counter.value + + async def async_update(self) -> None: + """Retrieve latest state.""" + self.async_write_ha_state() + + # pylint: disable-next=hass-invalid-inheritance # needs fixing class EnumSensor(Sensor): """Sensor with value from enum.""" @@ -251,6 +354,13 @@ class EnumSensor(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM _enum: type[enum.Enum] + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access + sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata + self._attribute_name = sensor_metadata.attribute_name + self._enum = sensor_metadata.enum + def formatter(self, value: int) -> str | None: """Use name of enum.""" assert self._enum is not None @@ -849,9 +959,10 @@ def create_entity( """Entity Factory. This attribute only started to be initialized in HA 2024.2.0, - so the entity would still be created on the first HA start after the upgrade for existing devices, - as the initialization to see if an attribute is unsupported happens later in the background. - To avoid creating a lot of unnecessary entities for existing devices, + so the entity would be created on the first HA start after the + upgrade for existing devices, as the initialization to see if + an attribute is unsupported happens later in the background. + To avoid creating unnecessary entities for existing devices, wait until the attribute was properly initialized once for now. """ if cluster_handlers[0].cluster.get(cls._attribute_name) is None: diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index afc73baca70bbb..960124c4a8a900 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF +from zigpy.quirks.v2 import EntityMetadata, SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -23,6 +24,7 @@ CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -173,6 +175,8 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): _attribute_name: str _inverter_attribute_name: str | None = None _force_inverted: bool = False + _off_value: int = 0 + _on_value: int = 1 @classmethod def create_entity( @@ -187,7 +191,7 @@ def create_entity( Return entity if it is a supported configuration, otherwise return None """ cluster_handler = cluster_handlers[0] - if ( + if QUIRK_METADATA not in kwargs and ( cls._attribute_name in cluster_handler.cluster.unsupported_attributes or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cluster_handler.cluster.get(cls._attribute_name) is None @@ -210,8 +214,22 @@ def __init__( ) -> None: """Init this number configuration entity.""" self._cluster_handler: ClusterHandler = cluster_handlers[0] + if QUIRK_METADATA in kwargs: + self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: + """Init this entity from the quirks metadata.""" + super()._init_from_quirks_metadata(entity_metadata) + switch_metadata: SwitchMetadata = entity_metadata.entity_metadata + self._attribute_name = switch_metadata.attribute_name + if switch_metadata.invert_attribute_name: + self._inverter_attribute_name = switch_metadata.invert_attribute_name + if switch_metadata.force_inverted: + self._force_inverted = switch_metadata.force_inverted + self._off_value = switch_metadata.off_value + self._on_value = switch_metadata.on_value + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() @@ -236,14 +254,25 @@ def inverted(self) -> bool: @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._cluster_handler.cluster.get(self._attribute_name)) + if self._on_value != 1: + val = self._cluster_handler.cluster.get(self._attribute_name) + val = val == self._on_value + else: + val = bool(self._cluster_handler.cluster.get(self._attribute_name)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" - await self._cluster_handler.write_attributes_safe( - {self._attribute_name: not state if self.inverted else state} - ) + if self.inverted: + state = not state + if state: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._on_value} + ) + else: + await self._cluster_handler.write_attributes_safe( + {self._attribute_name: self._off_value} + ) self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index e92424acf47823..d45c24253be814 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -1,17 +1,16 @@ """Representation of ZHA updates.""" from __future__ import annotations -from dataclasses import dataclass import functools +import logging +import math from typing import TYPE_CHECKING, Any -from zigpy.ota.image import BaseOTAImage -from zigpy.types import uint16_t +from zigpy.ota import OtaImageWithMetadata +from zigpy.zcl.clusters.general import Ota from zigpy.zcl.foundation import Status from homeassistant.components.update import ( - ATTR_INSTALLED_VERSION, - ATTR_LATEST_VERSION, UpdateDeviceClass, UpdateEntity, UpdateEntityFeature, @@ -22,36 +21,29 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import ExtraStoredData +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .core import discovery -from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, UNKNOWN -from .core.helpers import get_zha_data +from .core.const import CLUSTER_HANDLER_OTA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data, get_zha_gateway from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity if TYPE_CHECKING: + from zigpy.application import ControllerApplication + from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice +_LOGGER = logging.getLogger(__name__) + CONFIG_DIAGNOSTIC_MATCH = functools.partial( ZHA_ENTITIES.config_diagnostic_match, Platform.UPDATE ) -# don't let homeassistant check for updates button hammer the zigbee network -PARALLEL_UPDATES = 1 - - -@dataclass -class ZHAFirmwareUpdateExtraStoredData(ExtraStoredData): - """Extra stored data for ZHA firmware update entity.""" - - image_type: uint16_t | None - - def as_dict(self) -> dict[str, Any]: - """Return a dict representation of the extra data.""" - return {"image_type": self.image_type} - async def async_setup_entry( hass: HomeAssistant, @@ -62,18 +54,46 @@ async def async_setup_entry( zha_data = get_zha_data(hass) entities_to_create = zha_data.platforms[Platform.UPDATE] + coordinator = ZHAFirmwareUpdateCoordinator( + hass, get_zha_gateway(hass).application_controller + ) + unsub = async_dispatcher_connect( hass, SIGNAL_ADD_ENTITIES, functools.partial( - discovery.async_add_entities, async_add_entities, entities_to_create + discovery.async_add_entities, + async_add_entities, + entities_to_create, + coordinator=coordinator, ), ) config_entry.async_on_unload(unsub) +class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module + """Firmware update coordinator that broadcasts updates network-wide.""" + + def __init__( + self, hass: HomeAssistant, controller_application: ControllerApplication + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name="ZHA firmware update coordinator", + update_method=self.async_update_data, + ) + self.controller_application = controller_application + + async def async_update_data(self) -> None: + """Fetch the latest firmware update data.""" + # Broadcast to all devices + await self.controller_application.ota.broadcast_notify(jitter=100) + + @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_OTA) -class ZHAFirmwareUpdateEntity(ZhaEntity, UpdateEntity): +class ZHAFirmwareUpdateEntity(ZhaEntity, CoordinatorEntity, UpdateEntity): """Representation of a ZHA firmware update entity.""" _unique_id_suffix = "firmware_update" @@ -90,147 +110,114 @@ def __init__( unique_id: str, zha_device: ZHADevice, channels: list[ClusterHandler], + coordinator: ZHAFirmwareUpdateCoordinator, **kwargs: Any, ) -> None: """Initialize the ZHA update entity.""" super().__init__(unique_id, zha_device, channels, **kwargs) + CoordinatorEntity.__init__(self, coordinator) + self._ota_cluster_handler: ClusterHandler = self.cluster_handlers[ CLUSTER_HANDLER_OTA ] - self._attr_installed_version: str = self.determine_installed_version() - self._image_type: uint16_t | None = None - self._latest_version_firmware: BaseOTAImage | None = None - self._result = None + self._attr_installed_version: str | None = self._get_cluster_version() + self._attr_latest_version = self._attr_installed_version + self._latest_firmware: OtaImageWithMetadata | None = None + + def _get_cluster_version(self) -> str | None: + """Synchronize current file version with the cluster.""" + + device = self._ota_cluster_handler._endpoint.device # pylint: disable=protected-access + + if self._ota_cluster_handler.current_file_version is not None: + return f"0x{self._ota_cluster_handler.current_file_version:08x}" + + if device.sw_version is not None: + return device.sw_version + + return None @callback - def determine_installed_version(self) -> str: - """Determine the currently installed firmware version.""" - currently_installed_version = self._ota_cluster_handler.current_file_version - version_from_dr = self.zha_device.sw_version - if currently_installed_version == UNKNOWN and version_from_dr: - currently_installed_version = version_from_dr - return currently_installed_version - - @property - def extra_restore_state_data(self) -> ZHAFirmwareUpdateExtraStoredData: - """Return ZHA firmware update specific state data to be restored.""" - return ZHAFirmwareUpdateExtraStoredData(self._image_type) + def attribute_updated(self, attrid: int, name: str, value: Any) -> None: + """Handle attribute updates on the OTA cluster.""" + if attrid == Ota.AttributeDefs.current_file_version.id: + self._attr_installed_version = f"0x{value:08x}" + self.async_write_ha_state() @callback - def device_ota_update_available(self, image: BaseOTAImage) -> None: + def device_ota_update_available( + self, image: OtaImageWithMetadata, current_file_version: int + ) -> None: """Handle ota update available signal from Zigpy.""" - self._latest_version_firmware = image - self._attr_latest_version = f"0x{image.header.file_version:08x}" - self._image_type = image.header.image_type - self._attr_installed_version = self.determine_installed_version() + self._latest_firmware = image + self._attr_latest_version = f"0x{image.version:08x}" + self._attr_installed_version = f"0x{current_file_version:08x}" + + if image.metadata.changelog: + self._attr_release_summary = image.metadata.changelog + self.async_write_ha_state() @callback def _update_progress(self, current: int, total: int, progress: float) -> None: """Update install progress on event.""" - assert self._latest_version_firmware - self._attr_in_progress = int(progress) - self.async_write_ha_state() + # If we are not supposed to be updating, do nothing + if self._attr_in_progress is False: + return - @callback - def _reset_progress(self, write_state: bool = True) -> None: - """Reset update install progress.""" - self._result = None - self._attr_in_progress = False - if write_state: - self.async_write_ha_state() - - async def async_update(self) -> None: - """Handle the update entity service call to manually check for available firmware updates.""" - await super().async_update() - # check for updates in the HA settings menu can invoke this so we need to check if the device - # is mains powered so we don't get a ton of errors in the logs from sleepy devices. - if self.zha_device.available and self.zha_device.is_mains_powered: - await self._ota_cluster_handler.async_check_for_update() + # Remap progress to 2-100 to avoid 0 and 1 + self._attr_in_progress = int(math.ceil(2 + 98 * progress / 100)) + self.async_write_ha_state() async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - firmware = self._latest_version_firmware - assert firmware - self._reset_progress(False) + assert self._latest_firmware is not None + + # Set the progress to an indeterminate state self._attr_in_progress = True self.async_write_ha_state() try: - self._result = await self.zha_device.device.update_firmware( - self._latest_version_firmware, - self._update_progress, + result = await self.zha_device.device.update_firmware( + image=self._latest_firmware, + progress_callback=self._update_progress, ) except Exception as ex: - self._reset_progress() - raise HomeAssistantError(ex) from ex + raise HomeAssistantError(f"Update was not successful: {ex}") from ex - assert self._result is not None + # If we tried to install firmware that is no longer compatible with the device, + # bail out + if result == Status.NO_IMAGE_AVAILABLE: + self._attr_latest_version = self._attr_installed_version + self.async_write_ha_state() - # If the update was not successful, we should throw an error to let the user know - if self._result != Status.SUCCESS: - # save result since reset_progress will clear it - results = self._result - self._reset_progress() - raise HomeAssistantError(f"Update was not successful - result: {results}") + # If the update finished but was not successful, we should also throw an error + if result != Status.SUCCESS: + raise HomeAssistantError(f"Update was not successful: {result}") - # If we get here, all files were installed successfully - self._attr_installed_version = ( - self._attr_latest_version - ) = f"0x{firmware.header.file_version:08x}" - self._latest_version_firmware = None - self._reset_progress() + # Clear the state + self._latest_firmware = None + self._attr_in_progress = False + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Call when entity is added.""" await super().async_added_to_hass() - last_state = await self.async_get_last_state() - # If we have a complete previous state, use that to set the installed version - if ( - last_state - and self._attr_installed_version == UNKNOWN - and (installed_version := last_state.attributes.get(ATTR_INSTALLED_VERSION)) - ): - self._attr_installed_version = installed_version - # If we have a complete previous state, use that to set the latest version - if ( - last_state - and (latest_version := last_state.attributes.get(ATTR_LATEST_VERSION)) - is not None - and latest_version != UNKNOWN - ): - self._attr_latest_version = latest_version - # If we have no state or latest version to restore, or the latest version is - # the same as the installed version, we can set the latest - # version to installed so that the entity starts as off. - elif ( - not last_state - or not latest_version - or latest_version == self._attr_installed_version - ): - self._attr_latest_version = self._attr_installed_version - - if self._attr_latest_version != self._attr_installed_version and ( - extra_data := await self.async_get_last_extra_data() - ): - self._image_type = extra_data.as_dict()["image_type"] - if self._image_type: - self._latest_version_firmware = ( - await self.zha_device.device.application.ota.get_ota_image( - self.zha_device.manufacturer_code, self._image_type - ) - ) - # if we can't locate an image but we have a latest version that differs - # we should set the latest version to the installed version to avoid - # confusion and errors - if not self._latest_version_firmware: - self._attr_latest_version = self._attr_installed_version + # OTA events are sent by the device self.zha_device.device.add_listener(self) + self.async_accept_signal( + self._ota_cluster_handler, SIGNAL_ATTR_UPDATED, self.attribute_updated + ) async def async_will_remove_from_hass(self) -> None: """Call when entity will be removed.""" await super().async_will_remove_from_hass() - self._reset_progress(False) + self._attr_in_progress = False + + async def async_update(self) -> None: + """Update the entity.""" + await CoordinatorEntity.async_update(self) + await super().async_update() diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json index 77f85c9dfcd84b..06cc06faf0b172 100644 --- a/homeassistant/components/zhong_hong/manifest.json +++ b/homeassistant/components/zhong_hong/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/zhong_hong", "iot_class": "local_push", "loggers": ["zhong_hong_hvac"], - "requirements": ["zhong-hong-hvac==1.0.9"] + "requirements": ["zhong-hong-hvac==1.0.12"] } diff --git a/homeassistant/components/zondergas/__init__.py b/homeassistant/components/zondergas/__init__.py new file mode 100644 index 00000000000000..150414e001f776 --- /dev/null +++ b/homeassistant/components/zondergas/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: ZonderGas.""" diff --git a/homeassistant/components/zondergas/manifest.json b/homeassistant/components/zondergas/manifest.json new file mode 100644 index 00000000000000..09292e9d33095c --- /dev/null +++ b/homeassistant/components/zondergas/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "zondergas", + "name": "ZonderGas", + "integration_type": "virtual", + "supported_by": "energyzero" +} diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 1321ef36f8506c..1e2a17fdf63071 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -168,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_key="invalid_server_version", ) raise ConfigEntryNotReady(f"Invalid server version: {err}") from err - except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: + except (TimeoutError, BaseZwaveJSServerError) as err: raise ConfigEntryNotReady(f"Failed to connect: {err}") from err async_delete_issue(hass, DOMAIN, "invalid_server_version") diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 8d14c8ed5b6147..5aa27ada977e50 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2202,8 +2202,8 @@ async def post(self, request: web.Request, device_id: str) -> web.Response: node = async_get_node_from_device_id(hass, device_id, self._dev_reg) except ValueError as err: if "not loaded" in err.args[0]: - raise web_exceptions.HTTPBadRequest - raise web_exceptions.HTTPNotFound + raise web_exceptions.HTTPBadRequest from err + raise web_exceptions.HTTPNotFound from err # If this was not true, we wouldn't have been able to get the node from the # device ID above diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index f5ad8ce36cd7bc..2f84b52b7daeb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -139,10 +139,19 @@ def __init__( self._hvac_modes: dict[HVACMode, int | None] = {} self._hvac_presets: dict[str, int | None] = {} self._unit_value: ZwaveValue | None = None + self._last_hvac_mode_id_before_off: int | None = None self._current_mode = self.get_zwave_value( THERMOSTAT_MODE_PROPERTY, command_class=CommandClass.THERMOSTAT_MODE ) + self._supports_resume: bool = bool( + self._current_mode + and ( + str(ThermostatMode.RESUME_ON.value) + in self._current_mode.metadata.states + ) + ) + self._setpoint_values: dict[ThermostatSetpointType, ZwaveValue | None] = {} for enum in ThermostatSetpointType: self._setpoint_values[enum] = self.get_zwave_value( @@ -196,13 +205,9 @@ def __init__( self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if HVACMode.OFF in self._hvac_modes: self._attr_supported_features |= ClimateEntityFeature.TURN_OFF - # We can only support turn on if we are able to turn the device off, # otherwise the device can be considered always on - if len(self._hvac_modes) == 2 or any( - mode in self._hvac_modes - for mode in (HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL) - ): + if len(self._hvac_modes) > 1: self._attr_supported_features |= ClimateEntityFeature.TURN_ON # If any setpoint value exists, we can assume temperature # can be set @@ -496,8 +501,54 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Thermostat(valve) has no support for setting a mode, so we make it a no-op return + # When turning the HVAC off from an on state, store the last HVAC mode ID so we + # can set it again when turning the device back on. + if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF: + self._last_hvac_mode_id_before_off = self._current_mode.value await self._async_set_value(self._current_mode, hvac_mode_id) + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + # If current mode is not off, do nothing + if self.hvac_mode != HVACMode.OFF: + return + + # We can safely assert here because this function can only be called if the + # device can be turned off and on which would require the device to have the + # current mode Z-Wave Value + assert self._current_mode + + # If the device supports resume, use resume to get to the right mode + if self._supports_resume: + await self._async_set_value(self._current_mode, ThermostatMode.RESUME_ON) + return + + # If we have an HVAC mode ID from before the device was turned off, set it to + # that mode + if self._last_hvac_mode_id_before_off is not None: + await self._async_set_value( + self._current_mode, self._last_hvac_mode_id_before_off + ) + self._last_hvac_mode_id_before_off = None + return + + # Attempt to set the device to the first available mode among heat_cool, heat, + # and cool to mirror previous behavior. If none of those are available, set it + # to the first available mode that is not off. + try: + hvac_mode = next( + mode + for mode in (HVACMode.HEAT_COOL, HVACMode.HEAT, HVACMode.COOL) + if mode in self._hvac_modes + ) + except StopIteration: + hvac_mode = next(mode for mode in self._hvac_modes if mode != HVACMode.OFF) + await self.async_set_hvac_mode(hvac_mode) + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" assert self._current_mode is not None diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index e252a2ad69351d..c3fd28360482e1 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -118,7 +118,7 @@ async def async_get_version_info(hass: HomeAssistant, ws_address: str) -> Versio version_info: VersionInfo = await get_server_version( ws_address, async_get_clientsession(hass) ) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: + except (TimeoutError, aiohttp.ClientError) as err: # We don't want to spam the log if the add-on isn't started # or takes a long time to start. _LOGGER.debug("Failed to connect to Z-Wave JS server: %s", err) @@ -750,9 +750,7 @@ async def async_step_manual( } ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) return self.async_create_entry(title=TITLE, data={}) return self.async_show_form( @@ -917,9 +915,7 @@ async def async_step_finish_addon_setup( } ) # Always reload entry since we may have disconnected the client. - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) return self.async_create_entry(title=TITLE, data={}) async def async_revert_addon_config(self, reason: str) -> FlowResult: @@ -935,9 +931,7 @@ async def async_revert_addon_config(self, reason: str) -> FlowResult: ) if self.revert_reason or not self.original_addon_config: - self.hass.async_create_task( - self.hass.config_entries.async_reload(self.config_entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id) return self.async_abort(reason=reason) self.revert_reason = reason diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index b633e2a614f908..61a0cfdb80247f 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -564,6 +564,7 @@ class ConfigurableFanValueMappingDataTemplate( `configuration_value_to_fan_value_mapping` maps the values from `configuration_option` to the value mapping object. + """ def resolve_data( @@ -634,6 +635,7 @@ class FixedFanValueMappingDataTemplate( ) ), ), + """ def get_fan_value_mapping( diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 2b286240aa3acd..b105b556e246b9 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -410,18 +410,15 @@ def _get_color_values(self) -> tuple[Value | None, ...]: @callback def _calculate_color_support(self) -> None: """Calculate light colors.""" - (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() + (red, green, blue, warm_white, cool_white) = self._get_color_values() # RGB support - if red_val and green_val and blue_val: + if red and green and blue: self._supports_color = True # color temperature support - if ww_val and cw_val: + if warm_white and cool_white: self._supports_color_temp = True - # only one white channel (warm white) = rgbw support - elif red_val and green_val and blue_val and ww_val: - self._supports_rgbw = True - # only one white channel (cool white) = rgbw support - elif cw_val: + # only one white channel (warm white or cool white) = rgbw support + elif red and green and blue and warm_white or cool_white: self._supports_rgbw = True @callback diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a06de5cb8eeba2..40c896c516a049 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -5,6 +5,7 @@ "config_flow": true, "dependencies": ["http", "repairs", "usb", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "import_executor": true, "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 0240725ca2d675..af3bc8a622e685 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient @@ -19,7 +19,6 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.statistics import NodeStatisticsDataType -from zwave_js_server.model.value import ConfigurationValue from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -799,7 +798,6 @@ def __init__( super().__init__( config_entry, driver, info, entity_description, unit_of_measurement ) - self._primary_value = cast(ConfigurationValue, self.info.primary_value) property_key_name = self.info.primary_value.property_key_name # Entity class attributes diff --git a/homeassistant/config.py b/homeassistant/config.py index 8a868018adfe56..3e593a564a2db9 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -212,9 +212,11 @@ def _filter_bad_internal_external_urls(conf: dict) -> dict: return conf -PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs - vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config -) +# Schema for all packages element +PACKAGES_CONFIG_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list)}) + +# Schema for individual package definition +PACKAGE_DEFINITION_SCHEMA = vol.Schema({cv.string: vol.Any(dict, list, None)}) CUSTOMIZE_DICT_SCHEMA = vol.Schema( { @@ -499,7 +501,17 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: config.pop(invalid_domain) core_config = config.get(CONF_CORE, {}) - await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + try: + await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, [CONF_CORE, CONF_PACKAGES] + exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error( + "Invalid package configuration '%s'%s: %s", CONF_PACKAGES, suffix, exc + ) + core_config[CONF_PACKAGES] = {} + return config @@ -938,7 +950,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non def _log_pkg_error( - hass: HomeAssistant, package: str, component: str, config: dict, message: str + hass: HomeAssistant, package: str, component: str | None, config: dict, message: str ) -> None: """Log an error while merging packages.""" message_prefix = f"Setup of package '{package}'" @@ -996,6 +1008,12 @@ def _identify_config_schema(module: ComponentProtocol) -> str | None: return None +def _validate_package_definition(name: str, conf: Any) -> None: + """Validate basic package definition properties.""" + cv.slug(name) + PACKAGE_DEFINITION_SCHEMA(conf) + + def _recursive_merge(conf: dict[str, Any], package: dict[str, Any]) -> str | None: """Merge package into conf, recursively.""" duplicate_key: str | None = None @@ -1023,12 +1041,33 @@ async def merge_packages_config( config: dict, packages: dict[str, Any], _log_pkg_error: Callable[ - [HomeAssistant, str, str, dict, str], None + [HomeAssistant, str, str | None, dict, str], None ] = _log_pkg_error, ) -> dict: - """Merge packages into the top-level configuration. Mutate config.""" + """Merge packages into the top-level configuration. + + Ignores packages that cannot be setup. Mutates config. Raises + vol.Invalid if whole package config is invalid. + """ + PACKAGES_CONFIG_SCHEMA(packages) + + invalid_packages = [] for pack_name, pack_conf in packages.items(): + try: + _validate_package_definition(pack_name, pack_conf) + except vol.Invalid as exc: + _log_pkg_error( + hass, + pack_name, + None, + config, + f"Invalid package definition '{pack_name}': {str(exc)}. Package " + f"will not be initialized", + ) + invalid_packages.append(pack_name) + continue + for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue @@ -1123,6 +1162,9 @@ async def merge_packages_config( f"integration '{comp_name}' has duplicate key '{duplicate_key}'", ) + for pack_name in invalid_packages: + packages.pop(pack_name, {}) + return config diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e11ad3e823ee6e..1ca40886da28af 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -12,6 +12,7 @@ Mapping, ValuesView, ) +import contextlib from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -21,6 +22,8 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Any, Self, TypeVar, cast +from async_interrupt import interrupt + from . import data_entry_flow, loader from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform @@ -30,6 +33,7 @@ CoreState, Event, HassJob, + HassJobType, HomeAssistant, callback, ) @@ -49,13 +53,17 @@ async_call_later, ) from .helpers.frame import report +from .helpers.json import json_bytes, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component from .util import uuid as uuid_util +from .util.async_ import create_eager_task from .util.decorator import Registry if TYPE_CHECKING: + from functools import cached_property + from .components.bluetooth import BluetoothServiceInfoBleak from .components.dhcp import DhcpServiceInfo from .components.hassio import HassioServiceInfo @@ -63,6 +71,8 @@ from .components.usb import UsbServiceInfo from .components.zeroconf import ZeroconfServiceInfo from .helpers.service_info.mqtt import MqttServiceInfo +else: + from .backports.functools import cached_property _LOGGER = logging.getLogger(__name__) @@ -70,6 +80,7 @@ SOURCE_BLUETOOTH = "bluetooth" SOURCE_DHCP = "dhcp" SOURCE_DISCOVERY = "discovery" +SOURCE_HARDWARE = "hardware" SOURCE_HASSIO = "hassio" SOURCE_HOMEKIT = "homekit" SOURCE_IMPORT = "import" @@ -151,6 +162,7 @@ def recoverable(self) -> bool: SOURCE_BLUETOOTH, SOURCE_DHCP, SOURCE_DISCOVERY, + SOURCE_HARDWARE, SOURCE_HOMEKIT, SOURCE_IMPORT, SOURCE_INTEGRATION_DISCOVERY, @@ -217,40 +229,34 @@ class OperationNotAllowed(ConfigError): UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Coroutine[Any, Any, None]] +FROZEN_CONFIG_ENTRY_ATTRS = {"entry_id", "domain", "state", "reason"} +UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = { + "unique_id", + "title", + "data", + "options", + "pref_disable_new_entities", + "pref_disable_polling", + "minor_version", + "version", +} + class ConfigEntry: """Hold a configuration entry.""" - __slots__ = ( - "entry_id", - "version", - "minor_version", - "domain", - "title", - "data", - "options", - "unique_id", - "supports_unload", - "supports_remove_device", - "pref_disable_new_entities", - "pref_disable_polling", - "source", - "state", - "disabled_by", - "_setup_lock", - "update_listeners", - "reason", - "_async_cancel_retry_setup", - "_on_unload", - "reload_lock", - "_reauth_lock", - "_tasks", - "_background_tasks", - "_integration_for_domain", - "_tries", - "_setup_again_job", - "_supports_options", - ) + entry_id: str + domain: str + title: str + data: MappingProxyType[str, Any] + options: MappingProxyType[str, Any] + unique_id: str | None + state: ConfigEntryState + reason: str | None + pref_disable_new_entities: bool + pref_disable_polling: bool + version: int + minor_version: int def __init__( self, @@ -270,44 +276,45 @@ def __init__( disabled_by: ConfigEntryDisabler | None = None, ) -> None: """Initialize a config entry.""" + _setter = object.__setattr__ # Unique id of the config entry - self.entry_id = entry_id or uuid_util.random_uuid_hex() + _setter(self, "entry_id", entry_id or uuid_util.random_uuid_hex()) # Version of the configuration. - self.version = version - self.minor_version = minor_version + _setter(self, "version", version) + _setter(self, "minor_version", minor_version) # Domain the configuration belongs to - self.domain = domain + _setter(self, "domain", domain) # Title of the configuration - self.title = title + _setter(self, "title", title) # Config data - self.data = MappingProxyType(data) + _setter(self, "data", MappingProxyType(data)) # Entry options - self.options = MappingProxyType(options or {}) + _setter(self, "options", MappingProxyType(options or {})) # Entry system options if pref_disable_new_entities is None: pref_disable_new_entities = False - self.pref_disable_new_entities = pref_disable_new_entities + _setter(self, "pref_disable_new_entities", pref_disable_new_entities) if pref_disable_polling is None: pref_disable_polling = False - self.pref_disable_polling = pref_disable_polling + _setter(self, "pref_disable_polling", pref_disable_polling) # Source of the configuration (user, discovery, cloud) self.source = source # State of the entry (LOADED, NOT_LOADED) - self.state = state + _setter(self, "state", state) # Unique ID of this entry. - self.unique_id = unique_id + _setter(self, "unique_id", unique_id) # Config entry is disabled if isinstance(disabled_by, str) and not isinstance( @@ -337,7 +344,7 @@ def __init__( self.update_listeners: list[UpdateListenerType] = [] # Reason why config entry is in a failed state - self.reason: str | None = None + _setter(self, "reason", None) # Function to cancel a scheduled retry self._async_cancel_retry_setup: Callable[[], Any] | None = None @@ -357,7 +364,6 @@ def __init__( self._integration_for_domain: loader.Integration | None = None self._tries = 0 - self._setup_again_job: HassJob | None = None def __repr__(self) -> str: """Representation of ConfigEntry.""" @@ -366,14 +372,68 @@ def __repr__(self) -> str: f"title={self.title} state={self.state} unique_id={self.unique_id}>" ) + def __setattr__(self, key: str, value: Any) -> None: + """Set an attribute.""" + if key in UPDATE_ENTRY_CONFIG_ENTRY_ATTRS: + if key == "unique_id": + # Setting unique_id directly will corrupt internal state + # There is no deprecation period for this key + # as changing them will corrupt internal state + # so we raise an error here + raise AttributeError( + "unique_id cannot be changed directly, use async_update_entry instead" + ) + report( + f'sets "{key}" directly to update a config entry. This is deprecated and will' + " stop working in Home Assistant 2024.9, it should be updated to use" + " async_update_entry instead", + error_if_core=False, + ) + + elif key in FROZEN_CONFIG_ENTRY_ATTRS: + # These attributes are frozen and cannot be changed + # There is no deprecation period for these + # as changing them will corrupt internal state + # so we raise an error here + raise AttributeError(f"{key} cannot be changed") + + super().__setattr__(key, value) + self.clear_cache() + @property def supports_options(self) -> bool: """Return if entry supports config options.""" if self._supports_options is None and (handler := HANDLERS.get(self.domain)): # work out if handler has support for options flow - self._supports_options = handler.async_supports_options_flow(self) + object.__setattr__( + self, "_supports_options", handler.async_supports_options_flow(self) + ) return self._supports_options or False + def clear_cache(self) -> None: + """Clear cached properties.""" + with contextlib.suppress(AttributeError): + delattr(self, "as_json_fragment") + + @cached_property + def as_json_fragment(self) -> json_fragment: + """Return JSON fragment of a config entry.""" + json_repr = { + "entry_id": self.entry_id, + "domain": self.domain, + "title": self.title, + "source": self.source, + "state": self.state.value, + "supports_options": self.supports_options, + "supports_remove_device": self.supports_remove_device or False, + "supports_unload": self.supports_unload or False, + "pref_disable_new_entities": self.pref_disable_new_entities, + "pref_disable_polling": self.pref_disable_polling, + "disabled_by": self.disabled_by, + "reason": self.reason, + } + return json_fragment(json_bytes(json_repr)) + async def async_setup( self, hass: HomeAssistant, @@ -385,12 +445,12 @@ async def async_setup( if self.source == SOURCE_IGNORE or self.disabled_by: return - if integration is None: + if integration is None and not (integration := self._integration_for_domain): integration = await loader.async_get_integration(hass, self.domain) self._integration_for_domain = integration # Only store setup result as state if it was not forwarded. - if self.domain == integration.domain: + if domain_is_integration := self.domain == integration.domain: self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: @@ -409,15 +469,15 @@ async def async_setup( self.domain, err, ) - if self.domain == integration.domain: + if domain_is_integration: self._async_set_state( hass, ConfigEntryState.SETUP_ERROR, "Import error" ) return - if self.domain == integration.domain: + if domain_is_integration: try: - integration.get_platform("config_flow") + await integration.async_get_platform("config_flow") except ImportError as err: _LOGGER.error( ( @@ -475,12 +535,12 @@ async def async_setup( self.async_start_reauth(hass) result = False except ConfigEntryNotReady as exc: - self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(exc) or None) + message = str(exc) + self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, message or None) wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) self._tries += 1 - message = str(exc) ready_message = f"ready yet: {message}" if message else "ready yet" _LOGGER.debug( ( @@ -495,12 +555,18 @@ async def async_setup( if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( - hass, wait_time, self._async_get_setup_again_job(hass) + hass, + wait_time, + HassJob( + functools.partial(self._async_setup_again, hass), + job_type=HassJobType.Callback, + ), ) else: - self._async_cancel_retry_setup = hass.bus.async_listen_once( + self._async_cancel_retry_setup = hass.bus.async_listen( EVENT_HOMEASSISTANT_STARTED, functools.partial(self._async_setup_again, hass), + run_immediately=True, ) await self._async_process_on_unload(hass) @@ -512,40 +578,41 @@ async def async_setup( ) result = False - # Only store setup result as state if it was not forwarded. - if self.domain != integration.domain: - return - # - # It is important that this function does not yield to the - # event loop by using `await` or `async with` or similar until - # after the state has been set. Otherwise we risk that any `call_soon`s + # After successfully calling async_setup_entry, it is important that this function + # does not yield to the event loop by using `await` or `async with` or + # similar until after the state has been set by calling self._async_set_state. + # + # Otherwise we risk that any `call_soon`s # created by an integration will be executed before the state is set. # + + # Only store setup result as state if it was not forwarded. + if not domain_is_integration: + return + + self.async_cancel_retry_setup() + if result: self._async_set_state(hass, ConfigEntryState.LOADED, None) else: self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) - async def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: - """Run setup again.""" + @callback + def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: + """Schedule setup again. + + This method is a callback to ensure that _async_cancel_retry_setup + is unset as soon as its callback is called. + """ + self._async_cancel_retry_setup = None # Check again when we fire in case shutdown # has started so we do not block shutdown if not hass.is_stopping: - self._async_cancel_retry_setup = None - await self.async_setup(hass) + hass.async_create_task(self.async_setup(hass), eager_start=True) @callback - def _async_get_setup_again_job(self, hass: HomeAssistant) -> HassJob: - """Get a job that will call setup again.""" - if not self._setup_again_job: - self._setup_again_job = HassJob( - functools.partial(self._async_setup_again, hass), - cancel_on_shutdown=True, - ) - return self._setup_again_job - - async def async_shutdown(self) -> None: + def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" self.async_cancel_retry_setup() @@ -657,8 +724,10 @@ def _async_set_state( """Set the state of the config entry.""" if state not in NO_RESET_TRIES_STATES: self._tries = 0 - self.state = state - self.reason = reason + _setter = object.__setattr__ + _setter(self, "state", state) + _setter(self, "reason", reason) + self.clear_cache() async_dispatcher_send( hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self ) @@ -852,6 +921,7 @@ def async_create_task( hass: HomeAssistant, target: Coroutine[Any, Any, _R], name: str | None = None, + eager_start: bool = False, ) -> asyncio.Task[_R]: """Create a task from within the event loop. @@ -860,8 +930,10 @@ def async_create_task( target: target to call. """ task = hass.async_create_task( - target, f"{name} {self.title} {self.domain} {self.entry_id}" + target, f"{name} {self.title} {self.domain} {self.entry_id}", eager_start ) + if task.done(): + return task self._tasks.add(task) task.add_done_callback(self._tasks.remove) @@ -869,7 +941,11 @@ def async_create_task( @callback def async_create_background_task( - self, hass: HomeAssistant, target: Coroutine[Any, Any, _R], name: str + self, + hass: HomeAssistant, + target: Coroutine[Any, Any, _R], + name: str, + eager_start: bool = False, ) -> asyncio.Task[_R]: """Create a background task tied to the config entry lifecycle. @@ -877,7 +953,9 @@ def async_create_background_task( target: target to call. """ - task = hass.async_create_background_task(target, name) + task = hass.async_create_background_task(target, name, eager_start) + if task.done(): + return task self._background_tasks.add(task) task.add_done_callback(self._background_tasks.remove) return task @@ -888,6 +966,10 @@ def async_create_background_task( ) +class FlowCancelledError(Exception): + """Error to indicate that a flow has been cancelled.""" + + class ConfigEntriesFlowManager(data_entry_flow.FlowManager): """Manage all the config entry flows that are in progress.""" @@ -902,8 +984,8 @@ def __init__( self.config_entries = config_entries self._hass_config = hass_config self._pending_import_flows: dict[str, dict[str, asyncio.Future[None]]] = {} - self._initialize_tasks: dict[str, list[asyncio.Task]] = {} - self._discovery_debouncer = Debouncer( + self._initialize_futures: dict[str, list[asyncio.Future[None]]] = {} + self._discovery_debouncer = Debouncer[None]( hass, _LOGGER, cooldown=DISCOVERY_COOLDOWN, @@ -934,20 +1016,42 @@ async def async_init( raise KeyError("Context not set or doesn't have a source set") flow_id = uuid_util.random_uuid_hex() - if context["source"] == SOURCE_IMPORT: - init_done: asyncio.Future[None] = self.hass.loop.create_future() - self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done - 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) + # Avoid starting a config flow on an integration that only supports + # a single config entry, but which already has an entry + if ( + context.get("source") not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_UNIGNORE} + and await _support_single_config_entry_only(self.hass, handler) + and self.config_entries.async_entries(handler, include_ignore=False) + ): + return FlowResult( + type=data_entry_flow.FlowResultType.ABORT, + flow_id=flow_id, + handler=handler, + reason="single_instance_allowed", + translation_domain=HA_DOMAIN, + ) + loop = self.hass.loop + + if context["source"] == SOURCE_IMPORT: + self._pending_import_flows.setdefault(handler, {})[ + flow_id + ] = loop.create_future() + + cancel_init_future = loop.create_future() + self._initialize_futures.setdefault(handler, []).append(cancel_init_future) try: - flow, result = await task + async with interrupt( + cancel_init_future, + FlowCancelledError, + "Config entry initialize canceled: Home Assistant is shutting down", + ): + flow, result = await self._async_init(flow_id, handler, context, data) + except FlowCancelledError as ex: + raise asyncio.CancelledError from ex finally: - self._initialize_tasks[handler].remove(task) + self._initialize_futures[handler].remove(cancel_init_future) self._pending_import_flows.get(handler, {}).pop(flow_id, None) if result["type"] != data_entry_flow.FlowResultType.ABORT: @@ -980,14 +1084,13 @@ async def _async_init( init_done.set_result(None) return flow, result - async def async_shutdown(self) -> None: + @callback + def async_shutdown(self) -> None: """Cancel any initializing flows.""" - for task_list in self._initialize_tasks.values(): - for task in task_list: - task.cancel( - "Config entry initialize canceled: Home Assistant is shutting down" - ) - await self._discovery_debouncer.async_shutdown() + for future_list in self._initialize_futures.values(): + for future in future_list: + future.set_result(None) + self._discovery_debouncer.async_shutdown() async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult @@ -1018,6 +1121,21 @@ async def async_finish_flow( if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result + # Avoid adding a config entry for a integration + # that only supports a single config entry, but already has an entry + if ( + await _support_single_config_entry_only(self.hass, flow.handler) + and flow.context["source"] != SOURCE_IGNORE + and self.config_entries.async_entries(flow.handler, include_ignore=False) + ): + return FlowResult( + type=data_entry_flow.FlowResultType.ABORT, + flow_id=flow.flow_id, + handler=flow.handler, + reason="single_instance_allowed", + translation_domain=HA_DOMAIN, + ) + # Check if config entry exists with unique ID. Unload it. existing_entry = None @@ -1025,11 +1143,21 @@ async def async_finish_flow( # or the default discovery ID for progress_flow in self.async_progress_by_handler(flow.handler): progress_unique_id = progress_flow["context"].get("unique_id") - if progress_flow["flow_id"] != flow.flow_id and ( + progress_flow_id = progress_flow["flow_id"] + + if progress_flow_id != flow.flow_id and ( (flow.unique_id and progress_unique_id == flow.unique_id) or progress_unique_id == DEFAULT_DISCOVERY_UNIQUE_ID ): - self.async_abort(progress_flow["flow_id"]) + self.async_abort(progress_flow_id) + + # Abort any flows in progress for the same handler + # when integration allows only one config entry + if ( + progress_flow_id != flow.flow_id + and await _support_single_config_entry_only(self.hass, flow.handler) + ): + self.async_abort(progress_flow_id) if flow.unique_id is not None: # Reset unique ID when the default discovery ID has been used @@ -1147,6 +1275,10 @@ def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: _LOGGER.error("An entry with the id %s already exists", entry_id) self._unindex_entry(entry_id) data[entry_id] = entry + self._index_entry(entry) + + def _index_entry(self, entry: ConfigEntry) -> None: + """Index an entry.""" self._domain_index.setdefault(entry.domain, []).append(entry) if entry.unique_id is not None: unique_id_hash = entry.unique_id @@ -1192,6 +1324,17 @@ def __delitem__(self, entry_id: str) -> None: self._unindex_entry(entry_id) super().__delitem__(entry_id) + def update_unique_id(self, entry: ConfigEntry, new_unique_id: str | None) -> None: + """Update unique id for an entry. + + This method mutates the entry with the new unique id and updates the indexes. + """ + entry_id = entry.entry_id + self._unindex_entry(entry_id) + object.__setattr__(entry, "unique_id", new_unique_id) + self._index_entry(entry) + entry.clear_cache() + def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]: """Get entries for a domain.""" return self._domain_index.get(domain, []) @@ -1244,11 +1387,27 @@ def async_get_entry(self, entry_id: str) -> ConfigEntry | None: return self._entries.data.get(entry_id) @callback - def async_entries(self, domain: str | None = None) -> list[ConfigEntry]: + def async_entries( + self, + domain: str | None = None, + include_ignore: bool = True, + include_disabled: bool = True, + ) -> list[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: - return list(self._entries.values()) - return list(self._entries.get_entries_for_domain(domain)) + entries: Iterable[ConfigEntry] = self._entries.values() + else: + entries = self._entries.get_entries_for_domain(domain) + + if include_ignore and include_disabled: + return list(entries) + + return [ + entry + for entry in entries + if (include_ignore or entry.source != SOURCE_IGNORE) + and (include_disabled or not entry.disabled_by) + ] @callback def async_entry_for_domain_unique_id( @@ -1263,6 +1422,7 @@ async def async_add(self, entry: ConfigEntry) -> None: raise HomeAssistantError( f"An entry with the id {entry.entry_id} already exists." ) + self._entries[entry.entry_id] = entry self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) @@ -1317,18 +1477,12 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: self._async_dispatch(ConfigEntryChange.REMOVED, entry) return {"require_restart": not unload_success} - async def _async_shutdown(self, event: Event) -> None: + @callback + def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" - await asyncio.gather( - *( - 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() + for entry in self._entries.values(): + entry.async_shutdown() + self.flow.async_shutdown() async def async_initialize(self) -> None: """Initialize config entry config.""" @@ -1429,6 +1583,17 @@ async def async_unload(self, entry_id: str) -> bool: return await entry.async_unload(self.hass) + @callback + def async_schedule_reload(self, entry_id: str) -> None: + """Schedule a config entry to be reloaded.""" + if (entry := self.async_get_entry(entry_id)) is None: + raise UnknownEntry + entry.async_cancel_retry_setup() + self.hass.async_create_task( + self.async_reload(entry_id), + f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", + ) + async def async_reload(self, entry_id: str) -> bool: """Reload an entry. @@ -1497,12 +1662,14 @@ def async_update_entry( self, entry: ConfigEntry, *, - unique_id: str | None | UndefinedType = UNDEFINED, - title: str | UndefinedType = UNDEFINED, data: Mapping[str, Any] | UndefinedType = UNDEFINED, + minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, pref_disable_polling: bool | UndefinedType = UNDEFINED, + title: str | UndefinedType = UNDEFINED, + unique_id: str | None | UndefinedType = UNDEFINED, + version: int | UndefinedType = UNDEFINED, ) -> bool: """Update a config entry. @@ -1512,34 +1679,37 @@ def async_update_entry( If the entry was not changed, the update_listeners are not fired and this function returns False """ + if entry.entry_id not in self._entries: + raise UnknownEntry(entry.entry_id) + changed = False + _setter = object.__setattr__ if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Reindex the entry if the unique_id has changed - entry_id = entry.entry_id - del self._entries[entry_id] - entry.unique_id = unique_id - self._entries[entry_id] = entry + self._entries.update_unique_id(entry, unique_id) changed = True for attr, value in ( - ("title", title), + ("minor_version", minor_version), ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), + ("title", title), + ("version", version), ): if value is UNDEFINED or getattr(entry, attr) == value: continue - setattr(entry, attr, value) + _setter(entry, attr, value) changed = True if data is not UNDEFINED and entry.data != data: changed = True - entry.data = MappingProxyType(data) + _setter(entry, "data", MappingProxyType(data)) if options is not UNDEFINED and entry.options != options: changed = True - entry.options = MappingProxyType(options) + _setter(entry, "options", MappingProxyType(options)) if not changed: return False @@ -1551,6 +1721,7 @@ def async_update_entry( ) self._async_schedule_save() + entry.clear_cache() self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True @@ -1569,7 +1740,7 @@ async def async_forward_entry_setups( """Forward the setup of an entry to platforms.""" await asyncio.gather( *( - asyncio.create_task( + create_eager_task( self.async_forward_entry_setup(entry, platform), name=f"config entry forward setup {entry.title} {entry.domain} {entry.entry_id} {platform}", ) @@ -1605,7 +1776,7 @@ async def async_unload_platforms( return all( await asyncio.gather( *( - asyncio.create_task( + create_eager_task( self.async_forward_entry_unload(entry, platform), name=f"config entry forward unload {entry.title} {entry.domain} {entry.entry_id} {platform}", ) @@ -1645,8 +1816,11 @@ async def async_wait_component(self, entry: ConfigEntry) -> bool: Config entries which are created after Home Assistant is started can't be waited for, the function will just return if the config entry is loaded or not. """ - if setup_event := self.hass.data.get(DATA_SETUP_DONE, {}).get(entry.domain): - await setup_event.wait() + setup_done: dict[str, asyncio.Future[bool]] = self.hass.data.get( + DATA_SETUP_DONE, {} + ) + if setup_future := setup_done.get(entry.domain): + await setup_future # The component was not loaded. if entry.domain not in self.hass.config.components: return False @@ -1765,10 +1939,7 @@ def _abort_if_unique_id_configured( if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: return if should_reload: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id), - f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) raise data_entry_flow.AbortFlow(error) async def async_set_unique_id( @@ -1818,16 +1989,10 @@ def _async_current_entries( If the flow is user initiated, filter out ignored entries, unless include_ignore is True. """ - config_entries = self.hass.config_entries.async_entries(self.handler) - - if ( - include_ignore is True - or include_ignore is None - and self.source != SOURCE_USER - ): - return config_entries - - return [entry for entry in config_entries if entry.source != SOURCE_IGNORE] + return self.hass.config_entries.async_entries( + self.handler, + include_ignore or (include_ignore is None and self.source != SOURCE_USER), + ) @callback def _async_current_ids(self, include_ignore: bool = True) -> set[str | None]: @@ -2033,10 +2198,7 @@ def async_update_reload_and_abort( options=options, ) if result: - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id), - f"config entry reload {entry.title} {entry.domain} {entry.entry_id}", - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) return self.async_abort(reason=reason) @@ -2161,7 +2323,8 @@ def async_setup(self) -> None: event_filter=_handle_entry_updated_filter, ) - async def _handle_entry_updated(self, event: Event) -> None: + @callback + def _handle_entry_updated(self, event: Event) -> None: """Handle entity registry entry update.""" if self.registry is None: self.registry = entity_registry.async_get(self.hass) @@ -2258,6 +2421,12 @@ async def support_remove_from_device(hass: HomeAssistant, domain: str) -> bool: return hasattr(component, "async_remove_config_entry_device") +async def _support_single_config_entry_only(hass: HomeAssistant, domain: str) -> bool: + """Test if a domain supports only a single config entry.""" + integration = await loader.async_get_integration(hass, domain) + return integration.single_config_entry + + async def _load_integration( hass: HomeAssistant, domain: str, hass_config: ConfigType ) -> None: @@ -2269,16 +2438,15 @@ async def _load_integration( # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs(hass, hass_config, integration) - try: - integration.get_platform("config_flow") + await integration.async_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 + raise data_entry_flow.UnknownHandler from err async def _async_get_flow_handler( diff --git a/homeassistant/const.py b/homeassistant/const.py index 7d0cee153e0f67..78085695b0e01f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,8 +15,8 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 2 -PATCH_VERSION: Final = "5" +MINOR_VERSION: Final = 3 +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, 11, 0) @@ -1602,6 +1602,11 @@ class EntityCategory(StrEnum): SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" + +# hass.data key for logging information. +KEY_DATA_LOGGING = "logging" + + # Date/Time formats FORMAT_DATE: Final = "%Y-%m-%d" FORMAT_TIME: Final = "%H:%M:%S" diff --git a/homeassistant/core.py b/homeassistant/core.py index 4c59e88e84040a..0f038149d63fad 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -22,6 +22,7 @@ import datetime import enum import functools +import inspect import logging import os import pathlib @@ -90,9 +91,11 @@ from .util import dt as dt_util, location from .util.async_ import ( cancelling, + create_eager_task, run_callback_threadsafe, shutdown_run_callback_threadsafe, ) +from .util.executor import InterruptibleThreadPoolExecutor from .util.json import JsonObjectType from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager @@ -392,6 +395,9 @@ def __init__(self, config_dir: str) -> None: self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None self._shutdown_jobs: list[HassJobWithArgs] = [] + self.import_executor = InterruptibleThreadPoolExecutor( + max_workers=1, thread_name_prefix="ImportExecutor" + ) @cached_property def is_running(self) -> bool: @@ -621,7 +627,10 @@ def create_task( @callback def async_create_task( - self, target: Coroutine[Any, Any, _R], name: str | None = None + self, + target: Coroutine[Any, Any, _R], + name: str | None = None, + eager_start: bool = False, ) -> asyncio.Task[_R]: """Create a task from within the event loop. @@ -630,16 +639,19 @@ def async_create_task( target: target to call. """ - task = self.loop.create_task(target, name=name) + if eager_start: + task = create_eager_task(target, name=name, loop=self.loop) + if task.done(): + return task + else: + task = self.loop.create_task(target, name=name) self._tasks.add(task) task.add_done_callback(self._tasks.remove) return task @callback def async_create_background_task( - self, - target: Coroutine[Any, Any, _R], - name: str, + self, target: Coroutine[Any, Any, _R], name: str, eager_start: bool = False ) -> asyncio.Task[_R]: """Create a task from within the event loop. @@ -649,7 +661,12 @@ def async_create_background_task( This method must be run in the event loop. """ - task = self.loop.create_task(target, name=name) + if eager_start: + task = create_eager_task(target, name=name, loop=self.loop) + if task.done(): + return task + else: + task = self.loop.create_task(target, name=name) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.remove) return task @@ -665,6 +682,16 @@ def async_add_executor_job( return task + @callback + def async_add_import_executor_job( + self, target: Callable[..., _T], *args: Any + ) -> asyncio.Future[_T]: + """Add an import executor job from within the event loop.""" + task = self.loop.run_in_executor(self.import_executor, target, *args) + self._tasks.add(task) + task.add_done_callback(self._tasks.remove) + return task + @overload @callback def async_run_hass_job( @@ -875,7 +902,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: tasks.append(task_or_none) if tasks: await asyncio.gather(*tasks, return_exceptions=True) - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for shutdown jobs to complete, the shutdown will" " continue" @@ -906,7 +933,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: try: async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for integrations to stop, the shutdown will" " continue" @@ -919,7 +946,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: try: async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for final writes to complete, the shutdown will" " continue" @@ -951,7 +978,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: await task except asyncio.CancelledError: pass - except asyncio.TimeoutError: + except TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( "Task %s could not be canceled during final shutdown stage", task @@ -971,7 +998,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: try: async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() - except asyncio.TimeoutError: + except TimeoutError: _LOGGER.warning( "Timed out waiting for close event to be processed, the shutdown will" " continue" @@ -979,6 +1006,7 @@ async def async_stop(self, exit_code: int = 0, *, force: bool = False) -> None: self._async_log_running_tasks("close") self.set_state(CoreState.stopped) + self.import_executor.shutdown() if self._stopped is not None: self._stopped.set() @@ -1066,7 +1094,7 @@ class Event: def __init__( self, event_type: str, - data: dict[str, Any] | None = None, + data: Mapping[str, Any] | None = None, origin: EventOrigin = EventOrigin.local, time_fired: datetime.datetime | None = None, context: Context | None = None, @@ -1077,9 +1105,7 @@ def __init__( self.origin = origin self.time_fired = time_fired or dt_util.utcnow() if not context: - context = Context( - id=ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) - ) + context = Context(id=ulid_at_time(self.time_fired.timestamp())) self.context = context if not context.origin_event: context.origin_event = self @@ -1160,7 +1186,7 @@ class _OneTimeListener: remove: CALLBACK_TYPE | None = None @callback - def async_call(self, event: Event) -> None: + def __call__(self, event: Event) -> None: """Remove listener from event bus and then fire listener.""" if not self.remove: # If the listener was already removed, we don't need to do anything @@ -1169,6 +1195,13 @@ def async_call(self, event: Event) -> None: self.remove = None self.hass.async_run_job(self.listener, event) + def __repr__(self) -> str: + """Return the representation of the listener and source module.""" + module = inspect.getmodule(self.listener) + if module: + return f"<_OneTimeListener {module.__name__}:{self.listener}>" + return f"<_OneTimeListener {self.listener}>" + class EventBus: """Allow the firing of and listening for events.""" @@ -1198,7 +1231,7 @@ def listeners(self) -> dict[str, int]: def fire( self, event_type: str, - event_data: dict[str, Any] | None = None, + event_data: Mapping[str, Any] | None = None, origin: EventOrigin = EventOrigin.local, context: Context | None = None, ) -> None: @@ -1211,7 +1244,7 @@ def fire( def async_fire( self, event_type: str, - event_data: dict[str, Any] | None = None, + event_data: Mapping[str, Any] | None = None, origin: EventOrigin = EventOrigin.local, context: Context | None = None, time_fired: datetime.datetime | None = None, @@ -1366,7 +1399,7 @@ def async_listen_once( event_type, ( HassJob( - one_time_listener.async_call, + one_time_listener, f"onetime listen {event_type} {listener}", job_type=HassJobType.Callback, ), @@ -1757,7 +1790,9 @@ def get(self, entity_id: str) -> State | None: Async friendly. """ - return self._states_data.get(entity_id.lower()) + return self._states_data.get(entity_id) or self._states_data.get( + entity_id.lower() + ) def is_state(self, entity_id: str, state: str) -> bool: """Test if entity exists and is in specified state. @@ -1795,7 +1830,6 @@ def async_remove(self, entity_id: str, context: Context | None = None) -> bool: self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": None}, - EventOrigin.local, context=context, ) return True @@ -1870,10 +1904,16 @@ def async_set( This method must be run in the event loop. """ - entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - if (old_state := self._states_data.get(entity_id)) is None: + old_state = self._states_data.get(entity_id) + if old_state is None: + # If the state is missing, try to convert the entity_id to lowercase + # and try again. + entity_id = entity_id.lower() + old_state = self._states_data.get(entity_id) + + if old_state is None: same_state = False same_attr = False last_changed = None @@ -1924,8 +1964,7 @@ def async_set( self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, - EventOrigin.local, - context, + context=context, time_fired=now, ) @@ -2273,6 +2312,7 @@ async def async_call( self._hass.async_create_task( self._run_service_call_catch_exceptions(coro, service_call), f"service call background {service_call.domain}.{service_call.service}", + eager_start=True, ) return None diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index d08e76edbd2812..bbb6621cfcc846 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -157,6 +157,7 @@ class FlowResult(TypedDict, total=False): result: Any step_id: str title: str + translation_domain: str type: FlowResultType url: str version: int diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 586aa64ce18383..15ae2e369de368 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -13,8 +13,10 @@ "google_sheets", "google_tasks", "home_connect", + "husqvarna_automower", "lametric", "lyric", + "microbees", "myuplink", "neato", "nest", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7d32dbfe963ece..c0b21c0a81d439 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -340,6 +340,33 @@ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 4, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 5, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 6, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "mopeka", @@ -349,6 +376,33 @@ "manufacturer_id": 89, "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 9, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 10, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "mopeka", + "manufacturer_data_start": [ + 11, + ], + "manufacturer_id": 89, + "service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "mopeka", @@ -548,6 +602,11 @@ "domain": "thermopro", "local_name": "TP96*", }, + { + "connectable": False, + "domain": "thermopro", + "local_name": "TP97*", + }, { "domain": "tilt_ble", "manufacturer_data_start": [ diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa3efde99bc862..55d77e26336444 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ "aosmith", "apcupsd", "apple_tv", + "aprilaire", "aranet", "arcam_fmj", "aseko_pool_live", @@ -228,6 +229,7 @@ "hue", "huisbaasje", "hunterdouglas_powerview", + "husqvarna_automower", "huum", "hvv_departures", "hydrawise", @@ -253,7 +255,6 @@ "isy994", "izone", "jellyfin", - "juicenet", "justnimbus", "jvc_projector", "kaleidescape", @@ -307,6 +308,7 @@ "meteo_france", "meteoclimatic", "metoffice", + "microbees", "mikrotik", "mill", "minecraft_server", @@ -563,6 +565,7 @@ "v2c", "vallox", "velbus", + "velux", "venstar", "vera", "verisure", @@ -582,7 +585,9 @@ "watttime", "waze_travel_time", "weatherflow", + "weatherflow_cloud", "weatherkit", + "webmin", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index a6722282e35451..4f9f822e85eebf 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -971,6 +971,10 @@ "domain": "twinkly", "hostname": "twinkly_*", }, + { + "domain": "twinkly", + "hostname": "twinkly-*", + }, { "domain": "unifiprotect", "macaddress": "B4FBE4*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ae83918072979a..6b6c41e412c117 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -29,6 +29,11 @@ "config_flow": true, "iot_class": "local_push" }, + "acomax": { + "name": "Acomax", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "actiontec": { "name": "Actiontec", "integration_type": "hub", @@ -383,6 +388,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "aprilaire": { + "name": "Aprilaire", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "aprs": { "name": "APRS", "integration_type": "hub", @@ -1342,6 +1353,11 @@ "config_flow": true, "iot_class": "local_push" }, + "duquesne_light": { + "name": "Duquesne Light", + "integration_type": "virtual", + "supported_by": "opower" + }, "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "service", @@ -2613,6 +2629,12 @@ "integration_type": "virtual", "supported_by": "motion_blinds" }, + "husqvarna_automower": { + "name": "Husqvarna Automower", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "huum": { "name": "Huum", "integration_type": "hub", @@ -2889,12 +2911,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "juicenet": { - "name": "JuiceNet", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "justnimbus": { "name": "JustNimbus", "integration_type": "hub", @@ -3026,6 +3042,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "krispol": { + "name": "Krispol", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "kulersky": { "name": "Kuler Sky", "integration_type": "hub", @@ -3366,6 +3387,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "madeco": { + "name": "Madeco", + "integration_type": "virtual", + "supported_by": "motion_blinds" + }, "mailgun": { "name": "Mailgun", "integration_type": "hub", @@ -3519,6 +3545,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "microbees": { + "name": "microBees", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "microsoft": { "name": "Microsoft", "integrations": { @@ -3682,7 +3714,7 @@ "iot_class": "local_push" }, "motion_blinds": { - "name": "Motion Blinds", + "name": "Motionblinds", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" @@ -5075,6 +5107,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "samsam": { + "name": "SamSam", + "integration_type": "virtual", + "supported_by": "energyzero" + }, "samsung": { "name": "Samsung", "integrations": { @@ -5386,7 +5423,7 @@ "iot_class": "cloud_polling" }, "smart_blinds": { - "name": "Smart Blinds", + "name": "Smartblinds", "integration_type": "virtual", "supported_by": "motion_blinds" }, @@ -6180,7 +6217,7 @@ "traccar_server": { "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling", + "iot_class": "local_push", "name": "Traccar Server" } } @@ -6435,7 +6472,7 @@ "velux": { "name": "Velux", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "venstar": { @@ -6620,11 +6657,23 @@ "config_flow": true, "iot_class": "local_push" }, + "weatherflow_cloud": { + "name": "WeatherflowCloud", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "webhook": { "name": "Webhook", "integration_type": "hub", "config_flow": false }, + "webmin": { + "name": "Webmin", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "wemo": { "name": "Belkin WeMo", "integration_type": "hub", @@ -6953,6 +7002,11 @@ "config_flow": true, "iot_class": "calculated" }, + "zondergas": { + "name": "ZonderGas", + "integration_type": "virtual", + "supported_by": "energyzero" + }, "zoneminder": { "name": "ZoneMinder", "integration_type": "hub", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index ce40f481d9646c..faf8abb775c442 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -59,6 +59,12 @@ "pid": "EA60", "vid": "10C4", }, + { + "description": "*slzb-07*", + "domain": "zha", + "pid": "EA60", + "vid": "10C4", + }, { "description": "*sonoff*plus*", "domain": "zha", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index a66efa6dded750..0f16977097d758 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -620,6 +620,11 @@ "domain": "plugwise", }, ], + "_powerview-g3._tcp.local.": [ + { + "domain": "hunterdouglas_powerview", + }, + ], "_powerview._tcp.local.": [ { "domain": "hunterdouglas_powerview", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 74527a5922f405..cc0be0d55156be 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -187,7 +187,7 @@ async def async_aiohttp_proxy_web( # The user cancelled the request return None - except asyncio.TimeoutError as err: + except TimeoutError as err: # Timeout trying to start the web request raise HTTPGatewayTimeout() from err @@ -219,7 +219,7 @@ async def async_aiohttp_proxy_stream( await response.prepare(request) # Suppressing something went wrong fetching data, closed connection - with suppress(asyncio.TimeoutError, aiohttp.ClientError): + with suppress(TimeoutError, aiohttp.ClientError): while hass.is_running: async with asyncio.timeout(timeout): data = await stream.read(buffer_size) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index e55f71beb8811d..38c554ffda3da0 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -17,7 +17,7 @@ EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" STORAGE_KEY = "core.area_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 6 SAVE_DELAY = 10 @@ -33,8 +33,10 @@ class AreaEntry: """Area Registry Entry.""" aliases: set[str] + floor_id: str | None icon: str | None id: str + labels: set[str] = dataclasses.field(default_factory=set) name: str normalized_name: str picture: str | None @@ -113,6 +115,16 @@ async def _async_migrate_func( for area in old_data["areas"]: area["icon"] = None + if old_minor_version < 5: + # Version 1.5 adds floor_id + for area in old_data["areas"]: + area["floor_id"] = None + + if old_minor_version < 6: + # Version 1.6 adds labels + for area in old_data["areas"]: + area["labels"] = [] + if old_major_version > 1: raise NotImplementedError return old_data @@ -167,7 +179,9 @@ def async_create( name: str, *, aliases: set[str] | None = None, + floor_id: str | None = None, icon: str | None = None, + labels: set[str] | None = None, picture: str | None = None, ) -> AreaEntry: """Create a new area.""" @@ -179,8 +193,10 @@ def async_create( area_id = self._generate_area_id(name) area = AreaEntry( aliases=aliases or set(), + floor_id=floor_id, icon=icon, id=area_id, + labels=labels or set(), name=name, normalized_name=normalized_name, picture=picture, @@ -215,7 +231,9 @@ def async_update( area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + floor_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: @@ -223,7 +241,9 @@ def async_update( updated = self._async_update( area_id, aliases=aliases, + floor_id=floor_id, icon=icon, + labels=labels, name=name, picture=picture, ) @@ -238,7 +258,9 @@ def _async_update( area_id: str, *, aliases: set[str] | UndefinedType = UNDEFINED, + floor_id: str | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, name: str | UndefinedType = UNDEFINED, picture: str | None | UndefinedType = UNDEFINED, ) -> AreaEntry: @@ -250,7 +272,9 @@ def _async_update( for attr_name, value in ( ("aliases", aliases), ("icon", icon), + ("labels", labels), ("picture", picture), + ("floor_id", floor_id), ): if value is not UNDEFINED and value != getattr(old, attr_name): new_values[attr_name] = value @@ -269,6 +293,8 @@ def _async_update( async def async_load(self) -> None: """Load the area registry.""" + self._async_setup_cleanup() + data = await self._store.async_load() areas = AreaRegistryItems() @@ -279,8 +305,10 @@ async def async_load(self) -> None: normalized_name = normalize_area_name(area["name"]) areas[area["id"]] = AreaEntry( aliases=set(area["aliases"]), + floor_id=area["floor_id"], icon=area["icon"], id=area["id"], + labels=set(area["labels"]), name=area["name"], normalized_name=normalized_name, picture=area["picture"], @@ -302,8 +330,10 @@ def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: data["areas"] = [ { "aliases": list(entry.aliases), + "floor_id": entry.floor_id, "icon": entry.icon, "id": entry.id, + "labels": list(entry.labels), "name": entry.name, "picture": entry.picture, } @@ -321,6 +351,52 @@ def _generate_area_id(self, name: str) -> str: suggestion = f"{suggestion_base}_{tries}" return suggestion + @callback + def _async_setup_cleanup(self) -> None: + """Set up the area registry cleanup.""" + # pylint: disable-next=import-outside-toplevel + from . import ( # Circular dependencies + floor_registry as fr, + label_registry as lr, + ) + + @callback + def _removed_from_registry_filter( + event: fr.EventFloorRegistryUpdated | lr.EventLabelRegistryUpdated, + ) -> bool: + """Filter all except for the item removed from registry events.""" + return event.data["action"] == "remove" + + @callback + def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None: + """Update areas that are associated with a floor that has been removed.""" + floor_id = event.data["floor_id"] + for area_id, area in self.areas.items(): + if floor_id == area.floor_id: + self.async_update(area_id, floor_id=None) + + self.hass.bus.async_listen( + event_type=fr.EVENT_FLOOR_REGISTRY_UPDATED, + event_filter=_removed_from_registry_filter, # type: ignore[arg-type] + listener=_handle_floor_registry_update, # type: ignore[arg-type] + ) + + @callback + def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: + """Update areas that have a label that has been removed.""" + label_id = event.data["label_id"] + for area_id, area in self.areas.items(): + if label_id in area.labels: + labels = area.labels.copy() + labels.remove(label_id) + self.async_update(area_id, labels=labels) + + self.hass.bus.async_listen( + event_type=lr.EVENT_LABEL_REGISTRY_UPDATED, + event_filter=_removed_from_registry_filter, # type: ignore[arg-type] + listener=_handle_label_registry_update, # type: ignore[arg-type] + ) + @callback def async_get(hass: HomeAssistant) -> AreaRegistry: @@ -335,6 +411,18 @@ async def async_load(hass: HomeAssistant) -> None: await hass.data[DATA_REGISTRY].async_load() +@callback +def async_entries_for_floor(registry: AreaRegistry, floor_id: str) -> list[AreaEntry]: + """Return entries that match a floor.""" + return [area for area in registry.areas.values() if floor_id == area.floor_id] + + +@callback +def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaEntry]: + """Return entries that match a label.""" + return [area for area in registry.areas.values() if label_id in area.labels] + + def normalize_area_name(area_name: str) -> str: """Normalize an area name by removing whitespace and case folding.""" return area_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 1c8efadfdc5dba..b362d68ad55fe2 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -96,13 +96,13 @@ async def async_check_ha_config_file( # noqa: C901 def _pack_error( hass: HomeAssistant, package: str, - component: str, + component: str | None, config: ConfigType, message: str, ) -> None: """Handle errors from packages.""" message = f"Setup of package '{package}' failed: {message}" - domain = f"homeassistant.packages.{package}.{component}" + domain = f"homeassistant.packages.{package}{'.' + component if component is not None else ''}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_warning(message, domain, pack_config) @@ -157,10 +157,15 @@ async def _get_integration( return result.add_error(f"Error loading {config_path}: {err}") # Extract and validate core [homeassistant] config + core_config = config.pop(CONF_CORE, {}) try: - core_config = config.pop(CONF_CORE, {}) core_config = CORE_CONFIG_SCHEMA(core_config) result[CONF_CORE] = core_config + + # Merge packages + await merge_packages_config( + hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error + ) except vol.Invalid as err: result.add_error( format_schema_error(hass, err, CONF_CORE, core_config), @@ -168,11 +173,6 @@ async def _get_integration( core_config, ) core_config = {} - - # Merge packages - await merge_packages_config( - hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error - ) core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 80b40cf4fa007d..c3c2ae4ec37b7f 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass +from functools import partial from itertools import groupby import logging from operator import attrgetter @@ -151,7 +152,7 @@ def async_add_listener(self, listener: ChangeListener) -> Callable[[], None]: Will be called with (change_type, item_id, updated_config). """ self.listeners.append(listener) - return lambda: self.listeners.remove(listener) + return partial(self.listeners.remove, listener) @callback def async_add_change_set_listener( @@ -162,7 +163,7 @@ def async_add_change_set_listener( Will be called with [(change_type, item_id, updated_config), ...] """ self.change_set_listeners.append(listener) - return lambda: self.change_set_listeners.remove(listener) + return partial(self.change_set_listeners.remove, listener) async def notify_changes(self, change_sets: Iterable[CollectionChangeSet]) -> None: """Notify listeners of a change.""" @@ -418,79 +419,96 @@ async def async_load(self, data: list[dict]) -> None: ) -@callback -def sync_entity_lifecycle( - hass: HomeAssistant, - domain: str, - platform: str, - entity_component: EntityComponent[_EntityT], - collection: StorageCollection | YamlCollection, - entity_class: type[CollectionEntity], -) -> None: - """Map a collection to an entity component.""" - entities: dict[str, CollectionEntity] = {} - ent_reg = entity_registry.async_get(hass) +_GROUP_BY_KEY = attrgetter("change_type") - async def _add_entity(change_set: CollectionChangeSet) -> CollectionEntity: - def entity_removed() -> None: - """Remove entity from entities if it's removed or not added.""" - if change_set.item_id in entities: - entities.pop(change_set.item_id) - entities[change_set.item_id] = collection.create_entity( - entity_class, change_set.item - ) - entities[change_set.item_id].async_on_remove(entity_removed) - return entities[change_set.item_id] +@dataclass(slots=True, frozen=True) +class _CollectionLifeCycle(Generic[_EntityT]): + """Life cycle for a collection of entities.""" - async def _remove_entity(change_set: CollectionChangeSet) -> None: - ent_to_remove = ent_reg.async_get_entity_id( - domain, platform, change_set.item_id - ) + domain: str + platform: str + entity_component: EntityComponent[_EntityT] + collection: StorageCollection | YamlCollection + entity_class: type[CollectionEntity] + ent_reg: entity_registry.EntityRegistry + entities: dict[str, CollectionEntity] + + @callback + def async_setup(self) -> None: + """Set up the collection life cycle.""" + self.collection.async_add_change_set_listener(self._collection_changed) + + def _entity_removed(self, item_id: str) -> None: + """Remove entity from entities if it's removed or not added.""" + self.entities.pop(item_id, None) + + @callback + def _add_entity(self, change_set: CollectionChangeSet) -> CollectionEntity: + item_id = change_set.item_id + entity = self.collection.create_entity(self.entity_class, change_set.item) + self.entities[item_id] = entity + entity.async_on_remove(partial(self._entity_removed, item_id)) + return entity + + async def _remove_entity(self, change_set: CollectionChangeSet) -> None: + item_id = change_set.item_id + ent_reg = self.ent_reg + entities = self.entities + ent_to_remove = ent_reg.async_get_entity_id(self.domain, self.platform, item_id) if ent_to_remove is not None: ent_reg.async_remove(ent_to_remove) - elif change_set.item_id in entities: - await entities[change_set.item_id].async_remove(force_remove=True) + elif entity := entities.get(item_id): + await entity.async_remove(force_remove=True) # Unconditionally pop the entity from the entity list to avoid racing against # the entity registry event handled by Entity._async_registry_updated - if change_set.item_id in entities: - entities.pop(change_set.item_id) + entities.pop(item_id, None) - async def _update_entity(change_set: CollectionChangeSet) -> None: - if change_set.item_id not in entities: - return - await entities[change_set.item_id].async_update_config(change_set.item) + async def _update_entity(self, change_set: CollectionChangeSet) -> None: + if entity := self.entities.get(change_set.item_id): + await entity.async_update_config(change_set.item) - _func_map: dict[ - str, - Callable[[CollectionChangeSet], Coroutine[Any, Any, CollectionEntity | None]], - ] = { - CHANGE_ADDED: _add_entity, - CHANGE_REMOVED: _remove_entity, - CHANGE_UPDATED: _update_entity, - } - - async def _collection_changed(change_sets: Iterable[CollectionChangeSet]) -> None: + async def _collection_changed( + self, change_sets: Iterable[CollectionChangeSet] + ) -> None: """Handle a collection change.""" # 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. - groupby_key = attrgetter("change_type") - for _, grouped in groupby(change_sets, groupby_key): - new_entities = [ - entity - for entity in await asyncio.gather( - *( - _func_map[change_set.change_type](change_set) - for change_set in grouped - ) - ) - if entity is not None - ] - if new_entities: - await entity_component.async_add_entities(new_entities) + new_entities: list[CollectionEntity] = [] + coros: list[Coroutine[Any, Any, CollectionEntity | None]] = [] + grouped: Iterable[CollectionChangeSet] + for _, grouped in groupby(change_sets, _GROUP_BY_KEY): + for change_set in grouped: + change_type = change_set.change_type + if change_type == CHANGE_ADDED: + new_entities.append(self._add_entity(change_set)) + elif change_type == CHANGE_REMOVED: + coros.append(self._remove_entity(change_set)) + elif change_type == CHANGE_UPDATED: + coros.append(self._update_entity(change_set)) + + if coros: + await asyncio.gather(*coros) + + if new_entities: + await self.entity_component.async_add_entities(new_entities) - collection.async_add_change_set_listener(_collection_changed) + +@callback +def sync_entity_lifecycle( + hass: HomeAssistant, + domain: str, + platform: str, + entity_component: EntityComponent[_EntityT], + collection: StorageCollection | YamlCollection, + entity_class: type[CollectionEntity], +) -> None: + """Map a collection to an entity component.""" + ent_reg = entity_registry.async_get(hass) + _CollectionLifeCycle( + domain, platform, entity_component, collection, entity_class, ent_reg, {} + ).async_setup() class StorageCollectionWebsocket(Generic[_StorageCollectionT]): diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 0029a9c906bfb7..adbaa7e3efae0b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1055,9 +1055,9 @@ async def async_validate_conditions_config( hass: HomeAssistant, conditions: list[ConfigType] ) -> list[ConfigType | Template]: """Validate config.""" - return await asyncio.gather( - *(async_validate_condition_config(hass, cond) for cond in conditions) - ) + # No gather here because async_validate_condition_config is unlikely + # to suspend and the overhead of creating many tasks is not worth it + return [await async_validate_condition_config(hass, cond) for cond in conditions] @callback diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 7563d4c08b90c7..d99cc1d4f7609b 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -297,7 +297,7 @@ async def async_step_auth( try: async with asyncio.timeout(OAUTH_AUTHORIZE_URL_TIMEOUT_SEC): url = await self.async_generate_authorize_url() - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout generating authorize url: %s", err) return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: @@ -323,10 +323,11 @@ async def async_step_creation( token = await self.flow_impl.async_resolve_external_data( self.external_data ) - except asyncio.TimeoutError as err: + except TimeoutError as err: _LOGGER.error("Timeout resolving OAuth token: %s", err) return self.async_abort(reason="oauth_timeout") except (ClientResponseError, ClientError) as err: + _LOGGER.error("Error resolving OAuth token: %s", err) if ( isinstance(err, ClientResponseError) and err.status == HTTPStatus.UNAUTHORIZED diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index bdf9897a4baab1..59e4f09d26f874 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -10,7 +10,6 @@ timedelta, ) from enum import Enum, StrEnum -import inspect import logging from numbers import Number import os @@ -103,6 +102,7 @@ from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper +from .frame import get_integration_logger TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" @@ -364,6 +364,7 @@ def domain_key(config_key: Any) -> str: 'hue 1' returns 'hue' 'hue ' raises 'hue ' raises + """ if not isinstance(config_key, str): raise vol.Invalid("invalid domain", path=[config_key]) @@ -430,6 +431,19 @@ def icon(value: Any) -> str: raise vol.Invalid('Icons should be specified in the form "prefix:name"') +_COLOR_HEX = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE) + + +def color_hex(value: Any) -> str: + """Validate a hex color code.""" + str_value = str(value) + + if not _COLOR_HEX.match(str_value): + raise vol.Invalid("Color should be in the format #RRGGBB") + + return str_value + + _TIME_PERIOD_DICT_KEYS = ("days", "hours", "minutes", "seconds", "milliseconds") time_period_dict = vol.All( @@ -876,24 +890,17 @@ def _deprecated_or_removed( - No warning if neither key nor replacement_key are provided - Adds replacement_key with default value in this case """ - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - if option_removed: - logger_func = logging.getLogger(module_name).error - option_status = "has been removed" - else: - logger_func = logging.getLogger(module_name).warning - option_status = "is deprecated" def validator(config: dict) -> dict: """Check if key is in config and log warning or error.""" if key in config: + if option_removed: + level = logging.ERROR + option_status = "has been removed" + else: + level = logging.WARNING + option_status = "is deprecated" + try: near = ( f"near {config.__config_file__}" # type: ignore[attr-defined] @@ -914,7 +921,7 @@ def validator(config: dict) -> dict: if raise_if_present: raise vol.Invalid(warning % arguments) - logger_func(warning, *arguments) + get_integration_logger(__name__).log(level, warning, *arguments) value = config[key] if replacement_key or option_removed: config.pop(key) @@ -1098,19 +1105,9 @@ def expand_condition_shorthand(value: Any | None) -> Any: def empty_config_schema(domain: str) -> Callable[[dict], dict]: """Return a config schema which logs if there are configuration parameters.""" - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - logger_func = logging.getLogger(module_name).error - def validator(config: dict) -> dict: if domain in config and config[domain]: - logger_func( + get_integration_logger(__name__).error( ( "The %s integration does not support any configuration parameters, " "got %s. Please remove the configuration parameters from your " @@ -1132,16 +1129,6 @@ def _no_yaml_config_schema( ) -> Callable[[dict], dict]: """Return a config schema which logs if attempted to setup from YAML.""" - module = inspect.getmodule(inspect.stack(context=0)[2].frame) - if module is not None: - module_name = module.__name__ - else: - # If Python is unable to access the sources files, the call stack frame - # will be missing information, so let's guard. - # https://github.com/home-assistant/core/issues/24982 - module_name = __name__ - logger_func = logging.getLogger(module_name).error - def raise_issue() -> None: # pylint: disable-next=import-outside-toplevel from .issue_registry import IssueSeverity, async_create_issue @@ -1162,7 +1149,7 @@ def raise_issue() -> None: def validator(config: dict) -> dict: if domain in config: - logger_func( + get_integration_logger(__name__).error( ( "The %s integration does not support YAML setup, please remove it " "from your configuration file" diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 9fdd48b59f0eb9..695fbbf763385a 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol import voluptuous_serialize -from homeassistant import config_entries, data_entry_flow +from homeassistant import data_entry_flow from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator @@ -70,10 +70,7 @@ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response try: result = await self._flow_mgr.async_init( handler, # type: ignore[arg-type] - context={ - "source": config_entries.SOURCE_USER, - "show_advanced_options": data["show_advanced_options"], - }, + context=self.get_context(data), ) except data_entry_flow.UnknownHandler: return self.json_message("Invalid handler specified", HTTPStatus.NOT_FOUND) @@ -86,6 +83,10 @@ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response return self.json(result) + def get_context(self, data: dict[str, Any]) -> dict[str, Any]: + """Return context.""" + return {"show_advanced_options": data["show_advanced_options"]} + class FlowManagerResourceView(_BaseFlowManagerView): """View to interact with the flow manager.""" diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 54b90077cdc04e..298d20485a0c32 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -61,26 +61,44 @@ def function(self, function: Callable[[], _R_co]) -> None: f"debouncer cooldown={self.cooldown}, immediate={self.immediate}", ) - async def async_call(self) -> None: - """Call the function.""" + @callback + def async_schedule_call(self) -> None: + """Schedule a call to the function.""" + if self._async_schedule_or_call_now(): + self._execute_at_end_of_timer = True + self._on_debounce() + + def _async_schedule_or_call_now(self) -> bool: + """Check if a call should be scheduled. + + Returns True if the function should be called immediately. + + Returns False if there is nothing to do. + """ if self._shutdown_requested: self.logger.debug("Debouncer call ignored as shutdown has been requested.") - return - assert self._job is not None + return False if self._timer_task: if not self._execute_at_end_of_timer: self._execute_at_end_of_timer = True - return + return False # Locked means a call is in progress. Any call is good, so abort. if self._execute_lock.locked(): - return + return False if not self.immediate: self._execute_at_end_of_timer = True self._schedule_timer() + return False + + return True + + async def async_call(self) -> None: + """Call the function.""" + if not self._async_schedule_or_call_now(): return async with self._execute_lock: @@ -88,6 +106,7 @@ async def async_call(self) -> None: if self._timer_task: return + assert self._job is not None try: if task := self.hass.async_run_hass_job(self._job): await task @@ -118,7 +137,8 @@ async def _handle_timer_finish(self) -> None: # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() - async def async_shutdown(self) -> None: + @callback + def async_shutdown(self) -> None: """Cancel any scheduled call, and prevent new runs.""" self._shutdown_requested = True self.async_cancel() @@ -137,9 +157,11 @@ def _on_debounce(self) -> None: """Create job task, but only if pending.""" self._timer_task = None if self._execute_at_end_of_timer: + self._execute_at_end_of_timer = False self.hass.async_create_task( self._handle_timer_finish(), f"debouncer {self._job} finish cooldown={self.cooldown}, immediate={self.immediate}", + eager_start=True, ) @callback diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 52e779a3608ed0..826a4cc200e80d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -2,9 +2,9 @@ from __future__ import annotations from collections import UserDict -from collections.abc import Coroutine, ValuesView +from collections.abc import Mapping, ValuesView from enum import StrEnum -from functools import partial +from functools import lru_cache, partial import logging import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast @@ -15,12 +15,13 @@ from homeassistant.backports.functools import cached_property from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback, get_release_channel from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util -from . import storage +from . import storage, translation from .debounce import Debouncer from .deprecation import ( DeprecatedConstantEnum, @@ -43,7 +44,7 @@ EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated" STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 4 +STORAGE_VERSION_MINOR = 5 SAVE_DELAY = 10 CLEANUP_DELAY = 10 @@ -94,6 +95,8 @@ class DeviceInfo(TypedDict, total=False): suggested_area: str | None sw_version: str | None hw_version: str | None + translation_key: str | None + translation_placeholders: Mapping[str, str] | None via_device: tuple[str, str] @@ -238,6 +241,7 @@ class DeviceEntry: hw_version: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) identifiers: set[tuple[str, str]] = attr.ib(converter=set, factory=set) + labels: set[str] = attr.ib(converter=set, factory=set) manufacturer: str | None = attr.ib(default=None) model: str | None = attr.ib(default=None) name_by_user: str | None = attr.ib(default=None) @@ -320,6 +324,7 @@ def to_device_entry( ) +@lru_cache(maxsize=512) def format_mac(mac: str) -> str: """Format the mac address string for entry into dev reg.""" to_test = mac @@ -378,6 +383,10 @@ async def _async_migrate_func( # Introduced in 2023.11 for device in old_data["devices"]: device["serial_number"] = None + if old_minor_version < 5: + # Introduced in 2024.3 + for device in old_data["devices"]: + device["labels"] = device.get("labels", []) if old_major_version > 1: raise NotImplementedError @@ -491,6 +500,33 @@ def _async_get_deleted_device( """Check if device is deleted.""" return self.deleted_devices.get_entry(identifiers, connections) + def _substitute_name_placeholders( + self, + domain: str, + name: str, + translation_placeholders: Mapping[str, str], + ) -> str: + """Substitute placeholders in entity name.""" + try: + return name.format(**translation_placeholders) + except KeyError as err: + if get_release_channel() != "stable": + raise HomeAssistantError("Missing placeholder %s" % err) from err + report_issue = async_suggest_report_issue( + self.hass, integration_domain=domain + ) + _LOGGER.warning( + ( + "Device from integration %s has translation placeholders '%s' " + "which do not match the name '%s', please %s" + ), + domain, + translation_placeholders, + name, + report_issue, + ) + return name + @callback def async_get_or_create( self, @@ -512,12 +548,32 @@ def async_get_or_create( serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, + translation_key: str | None = None, + translation_placeholders: Mapping[str, str] | None = None, via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" if configuration_url is not UNDEFINED: configuration_url = _validate_configuration_url(configuration_url) + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + if config_entry is None: + raise HomeAssistantError( + f"Can't link device to unknown config entry {config_entry_id}" + ) + + if translation_key: + full_translation_key = ( + f"component.{config_entry.domain}.device.{translation_key}.name" + ) + translations = translation.async_get_cached_translations( + self.hass, self.hass.config.language, "device", config_entry.domain + ) + translated_name = translations.get(full_translation_key, translation_key) + name = self._substitute_name_placeholders( + config_entry.domain, translated_name, translation_placeholders or {} + ) + # Reconstruct a DeviceInfo dict from the arguments. # When we upgrade to Python 3.12, we can change this method to instead # accept kwargs typed as a DeviceInfo dict (PEP 692) @@ -543,11 +599,6 @@ def async_get_or_create( continue device_info[key] = val # type: ignore[literal-required] - config_entry = self.hass.config_entries.async_get_entry(config_entry_id) - if config_entry is None: - raise HomeAssistantError( - f"Can't link device to unknown config entry {config_entry_id}" - ) device_info_type = _validate_device_info(config_entry, device_info) if identifiers is None or identifiers is UNDEFINED: @@ -634,6 +685,7 @@ def async_update_device( disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, manufacturer: str | None | UndefinedType = UNDEFINED, merge_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, merge_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, @@ -648,10 +700,6 @@ def async_update_device( via_device_id: str | None | UndefinedType = UNDEFINED, ) -> DeviceEntry | None: """Update device attributes.""" - # Circular dep - # pylint: disable-next=import-outside-toplevel - from . import area_registry as ar - old = self.devices[device_id] new_values: dict[str, Any] = {} # Dict with new key/value pairs @@ -682,6 +730,10 @@ def async_update_device( and area_id is UNDEFINED and old.area_id is None ): + # Circular dep + # pylint: disable-next=import-outside-toplevel + from . import area_registry as ar + area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id @@ -728,6 +780,7 @@ def async_update_device( ("disabled_by", disabled_by), ("entry_type", entry_type), ("hw_version", hw_version), + ("labels", labels), ("manufacturer", manufacturer), ("model", model), ("name", name), @@ -822,6 +875,7 @@ async def async_load(self) -> None: tuple(iden) # type: ignore[misc] for iden in device["identifiers"] }, + labels=set(device["labels"]), manufacturer=device["manufacturer"], model=device["model"], name_by_user=device["name_by_user"], @@ -865,6 +919,7 @@ def _data_to_save(self) -> dict[str, list[dict[str, Any]]]: "hw_version": entry.hw_version, "id": entry.id, "identifiers": list(entry.identifiers), + "labels": list(entry.labels), "manufacturer": entry.manufacturer, "model": entry.model, "name_by_user": entry.name_by_user, @@ -937,6 +992,15 @@ def async_clear_area_id(self, area_id: str) -> None: if area_id == device.area_id: self.async_update_device(dev_id, area_id=None) + @callback + def async_clear_label_id(self, label_id: str) -> None: + """Clear label from registry entries.""" + for device_id, entry in self.devices.items(): + if label_id in entry.labels: + labels = entry.labels.copy() + labels.remove(label_id) + self.async_update_device(device_id, labels=labels) + @callback def async_get(hass: HomeAssistant) -> DeviceRegistry: @@ -957,6 +1021,14 @@ def async_entries_for_area(registry: DeviceRegistry, area_id: str) -> list[Devic return [device for device in registry.devices.values() if device.area_id == area_id] +@callback +def async_entries_for_label( + registry: DeviceRegistry, label_id: str +) -> list[DeviceEntry]: + """Return entries that match a label.""" + return [device for device in registry.devices.values() if label_id in device.labels] + + @callback def async_entries_for_config_entry( registry: DeviceRegistry, config_entry_id: str @@ -1051,20 +1123,41 @@ def async_cleanup( @callback def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: """Clean up device registry when entities removed.""" - from . import entity_registry # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from . import entity_registry, label_registry as lr + + @callback + def _label_removed_from_registry_filter( + event: lr.EventLabelRegistryUpdated, + ) -> bool: + """Filter all except for the remove action from label registry events.""" + return event.data["action"] == "remove" - async def cleanup() -> None: + @callback + def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: + """Update devices that have a label that has been removed.""" + dev_reg.async_clear_label_id(event.data["label_id"]) + + hass.bus.async_listen( + event_type=lr.EVENT_LABEL_REGISTRY_UPDATED, + event_filter=_label_removed_from_registry_filter, # type: ignore[arg-type] + listener=_handle_label_registry_update, # type: ignore[arg-type] + ) + + @callback + def _async_cleanup() -> None: """Cleanup.""" ent_reg = entity_registry.async_get(hass) async_cleanup(hass, dev_reg, ent_reg) - debounced_cleanup: Debouncer[Coroutine[Any, Any, None]] = Debouncer( - hass, _LOGGER, cooldown=CLEANUP_DELAY, immediate=False, function=cleanup + debounced_cleanup: Debouncer[None] = Debouncer( + hass, _LOGGER, cooldown=CLEANUP_DELAY, immediate=False, function=_async_cleanup ) - async def entity_registry_changed(event: Event) -> None: + @callback + def _async_entity_registry_changed(event: Event) -> None: """Handle entity updated or removed dispatch.""" - await debounced_cleanup.async_call() + debounced_cleanup.async_schedule_call() @callback def entity_registry_changed_filter(event: Event) -> bool: @@ -1080,7 +1173,7 @@ def entity_registry_changed_filter(event: Event) -> bool: if hass.is_running: hass.bus.async_listen( entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - entity_registry_changed, + _async_entity_registry_changed, event_filter=entity_registry_changed_filter, ) return @@ -1089,7 +1182,7 @@ async def startup_clean(event: Event) -> None: """Clean up on startup.""" hass.bus.async_listen( entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, - entity_registry_changed, + _async_entity_registry_changed, event_filter=entity_registry_changed_filter, ) await debounced_cleanup.async_call() diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 7ad9caa5a93ef7..c4698de1f52f4e 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -29,7 +29,9 @@ 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, f"discovery flow {domain} {context}") + hass.async_create_task( + init_coro, f"discovery flow {domain} {context}", eager_start=True + ) return return dispatcher.async_create(domain, context, data) @@ -86,17 +88,20 @@ async def _async_start(self, event: Event) -> None: pending_flows = self.pending_flows self.pending_flows = {} self.started = True - init_coros = [ - _async_init_flow( - self.hass, flow_key.domain, flow_values.context, flow_values.data - ) + init_coros = ( + init_coro for flow_key, flows in pending_flows.items() for flow_values in flows - ] - await gather_with_limited_concurrency( - FLOW_INIT_LIMIT, - *[init_coro for init_coro in init_coros if init_coro is not None], + if ( + init_coro := _async_init_flow( + self.hass, + flow_key.domain, + flow_values.context, + flow_values.data, + ) + ) ) + await gather_with_limited_concurrency(FLOW_INIT_LIMIT, *init_coros) @callback def async_create(self, domain: str, context: dict[str, Any], data: Any) -> None: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 32aa97ab8fef42..3517d41314bc35 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -496,6 +496,9 @@ class Entity( # Entry in the entity registry registry_entry: er.RegistryEntry | None = None + # If the entity is removed from the entity registry + _removed_from_registry: bool = False + # The device entry for this entity device_entry: dr.DeviceEntry | None = None @@ -529,7 +532,7 @@ class Entity( __capabilities_updated_at: deque[float] __capabilities_updated_at_reported: bool = False - __remove_event: asyncio.Event | None = None + __remove_future: asyncio.Future[None] | None = None # Entity Properties _attr_assumed_state: bool = False @@ -1335,15 +1338,18 @@ async def async_remove(self, *, force_remove: bool = False) -> None: If the entity doesn't have a non disabled entry in the entity registry, or if force_remove=True, its state will be removed. """ - if self.__remove_event is not None: - await self.__remove_event.wait() + if self.__remove_future is not None: + await self.__remove_future return - self.__remove_event = asyncio.Event() + self.__remove_future = self.hass.loop.create_future() try: await self.__async_remove_impl(force_remove) + except BaseException as ex: + self.__remove_future.set_exception(ex) + raise finally: - self.__remove_event.set() + self.__remove_future.set_result(None) @final async def __async_remove_impl(self, force_remove: bool) -> None: @@ -1361,6 +1367,17 @@ async def __async_remove_impl(self, force_remove: bool) -> None: not force_remove and self.registry_entry and not self.registry_entry.disabled + # Check if entity is still in the entity registry + # by checking self._removed_from_registry + # + # Because self.registry_entry is unset in a task, + # its possible that the entity has been removed but + # the task has not yet been executed. + # + # self._removed_from_registry is set to True in a + # callback which does not have the same issue. + # + and not self._removed_from_registry ): # Set the entity's state will to unavailable + ATTR_RESTORED: True self.registry_entry.write_unavailable_state(self.hass) @@ -1430,10 +1447,23 @@ async def async_internal_will_remove_from_hass(self) -> None: if self.platform: self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id) - async def _async_registry_updated( + @callback + def _async_registry_updated( self, event: EventType[er.EventEntityRegistryUpdatedData] ) -> None: """Handle entity registry update.""" + action = event.data["action"] + is_remove = action == "remove" + self._removed_from_registry = is_remove + if action == "update" or is_remove: + self.hass.async_create_task( + self._async_process_registry_update_or_remove(event), eager_start=True + ) + + async def _async_process_registry_update_or_remove( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: + """Handle entity registry update or remove.""" data = event.data if data["action"] == "remove": await self.async_removed_from_registry() @@ -1469,8 +1499,8 @@ async def _async_registry_updated( self.entity_id = registry_entry.entity_id - # Clear the remove event to handle entity added again after entity id change - self.__remove_event = None + # Clear the remove future to handle entity added again after entity id change + self.__remove_future = None self._platform_state = EntityPlatformState.NOT_ADDED await self.platform.async_add_entities([self]) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 5020c5c4271fa3..389dd69900a1b9 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -5,7 +5,6 @@ from collections.abc import Callable, Iterable from datetime import timedelta from functools import partial -from itertools import chain import logging from types import ModuleType from typing import Any, Generic @@ -148,6 +147,7 @@ async def async_setup(self, config: ConfigType) -> None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), f"EntityComponent setup platform {p_type} {self.domain}", + eager_start=True, ) # Generic discovery listener for loading platform dynamically @@ -394,8 +394,8 @@ def _async_init_entity_platform( entity_platform.async_prepare() return entity_platform - async def _async_shutdown(self, event: Event) -> None: + @callback + def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" - await asyncio.gather( - *(platform.async_shutdown() for platform in chain(self._platforms.values())) - ) + for platform in self._platforms.values(): + platform.async_shutdown() diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index db2760d554ce17..3a441e75e84ec0 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -32,6 +32,7 @@ from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.generated import languages from homeassistant.setup import async_start_setup +from homeassistant.util.async_ import create_eager_task from . import ( config_validation as cv, @@ -259,7 +260,7 @@ async def async_setup( return @callback - def async_create_setup_task() -> ( + def async_create_setup_awaitable() -> ( Coroutine[Any, Any, None] | asyncio.Future[None] ): """Get task to set up platform.""" @@ -282,9 +283,10 @@ def async_create_setup_task() -> ( discovery_info, ) - await self._async_setup_platform(async_create_setup_task) + await self._async_setup_platform(async_create_setup_awaitable) - async def async_shutdown(self) -> None: + @callback + def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" self.async_cancel_retry_setup() self.async_unsub_polling() @@ -303,7 +305,7 @@ async def async_setup_entry(self, config_entry: config_entries.ConfigEntry) -> b platform = self.platform @callback - def async_create_setup_task() -> Coroutine[Any, Any, None]: + def async_create_setup_awaitable() -> Coroutine[Any, Any, None]: """Get task to set up platform.""" config_entries.current_entry.set(config_entry) @@ -311,14 +313,16 @@ def async_create_setup_task() -> Coroutine[Any, Any, None]: self.hass, config_entry, self._async_schedule_add_entities_for_entry ) - return await self._async_setup_platform(async_create_setup_task) + return await self._async_setup_platform(async_create_setup_awaitable) async def _async_setup_platform( - self, async_create_setup_task: Callable[[], Awaitable[None]], tries: int = 0 + self, + async_create_setup_awaitable: Callable[[], Awaitable[None]], + tries: int = 0, ) -> bool: """Set up a platform via config file or config entry. - async_create_setup_task creates a coroutine that sets up platform. + async_create_setup_awaitable creates an awaitable that sets up platform. """ current_platform.set(self) logger = self.logger @@ -338,18 +342,20 @@ async def _async_setup_platform( ) with async_start_setup(hass, [full_name]): try: - task = async_create_setup_task() + awaitable = async_create_setup_awaitable() + if asyncio.iscoroutine(awaitable): + awaitable = create_eager_task(awaitable) async with hass.timeout.async_timeout(SLOW_SETUP_MAX_WAIT, self.domain): - await asyncio.shield(task) + await asyncio.shield(awaitable) # Block till all entities are done while self._tasks: - pending = [task for task in self._tasks if not task.done()] + # Await all tasks even if they are done + # to ensure exceptions are propagated + pending = self._tasks.copy() self._tasks.clear() - - if pending: - await asyncio.gather(*pending) + await asyncio.gather(*pending) hass.config.components.add(full_name) self._setup_complete = True @@ -377,7 +383,9 @@ async def _async_setup_platform( async def setup_again(*_args: Any) -> None: """Run setup again.""" self._async_cancel_retry_setup = None - await self._async_setup_platform(async_create_setup_task, tries) + await self._async_setup_platform( + async_create_setup_awaitable, tries + ) if hass.state is CoreState.running: self._async_cancel_retry_setup = async_call_later( @@ -388,7 +396,7 @@ async def setup_again(*_args: Any) -> None: EVENT_HOMEASSISTANT_STARTED, setup_again ) return False - except asyncio.TimeoutError: + except TimeoutError: logger.error( ( "Setup of platform %s is taking longer than %s seconds." @@ -468,6 +476,7 @@ def _async_schedule_add_entities( 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}", + eager_start=True, ) if not self._setup_complete: @@ -483,6 +492,7 @@ def _async_schedule_add_entities_for_entry( 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}", + eager_start=True, ) if not self._setup_complete: @@ -504,6 +514,83 @@ def add_entities( self.hass.loop, ).result() + async def _async_add_and_update_entities( + self, + coros: list[Coroutine[Any, Any, None]], + entities: list[Entity], + timeout: float, + ) -> None: + """Add entities for a single platform and update them. + + Since we are updating the entities before adding them, we need to + schedule the coroutines as tasks so we can await them in the event + loop. This is because the update is likely to yield control to the + event loop and will finish faster if we run them concurrently. + """ + results: list[BaseException | None] | None = None + tasks = [create_eager_task(coro) for coro in coros] + try: + async with self.hass.timeout.async_timeout(timeout, self.domain): + results = await asyncio.gather(*tasks, return_exceptions=True) + except TimeoutError: + self.logger.warning( + "Timed out adding entities for domain %s with platform %s after %ds", + self.domain, + self.platform_name, + timeout, + ) + + if not results: + return + + for idx, result in enumerate(results): + if isinstance(result, Exception): + entity = entities[idx] + self.logger.exception( + "Error adding entity %s for domain %s with platform %s", + entity.entity_id, + self.domain, + self.platform_name, + exc_info=result, + ) + elif isinstance(result, BaseException): + raise result + + async def _async_add_entities( + self, + coros: list[Coroutine[Any, Any, None]], + entities: list[Entity], + timeout: float, + ) -> None: + """Add entities for a single platform without updating. + + In this case we are not updating the entities before adding them + which means its unlikely that we will not have to yield control + to the event loop so we can await the coros directly without + scheduling them as tasks. + """ + try: + async with self.hass.timeout.async_timeout(timeout, self.domain): + for idx, coro in enumerate(coros): + try: + await coro + except Exception as ex: # pylint: disable=broad-except + entity = entities[idx] + self.logger.exception( + "Error adding entity %s for domain %s with platform %s", + entity.entity_id, + self.domain, + self.platform_name, + exc_info=ex, + ) + except TimeoutError: + self.logger.warning( + "Timed out adding entities for domain %s with platform %s after %ds", + self.domain, + self.platform_name, + timeout, + ) + async def async_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -516,50 +603,46 @@ async def async_add_entities( return hass = self.hass - entity_registry = ent_reg.async_get(hass) - tasks = [ - self._async_add_entity(entity, update_before_add, entity_registry) - for entity in new_entities - ] + coros: list[Coroutine[Any, Any, None]] = [] + entities: list[Entity] = [] + for entity in new_entities: + coros.append( + self._async_add_entity(entity, update_before_add, entity_registry) + ) + entities.append(entity) # No entities for processing - if not tasks: + if not coros: return - timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(tasks), SLOW_ADD_MIN_TIMEOUT) - try: - async with self.hass.timeout.async_timeout(timeout, self.domain): - await asyncio.gather(*tasks) - except asyncio.TimeoutError: - self.logger.warning( - "Timed out adding entities for domain %s with platform %s after %ds", - self.domain, - self.platform_name, - timeout, - ) - except Exception: - self.logger.exception( - "Error adding entities for domain %s with platform %s", - self.domain, - self.platform_name, - ) - raise + timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT) + if update_before_add: + add_func = self._async_add_and_update_entities + else: + add_func = self._async_add_entities + + await add_func(coros, entities, timeout) if ( (self.config_entry and self.config_entry.pref_disable_polling) or self._async_unsub_polling is not None - or not any(entity.should_poll for entity in self.entities.values()) + or not any(entity.should_poll for entity in entities) ): return self._async_unsub_polling = async_track_time_interval( self.hass, - self._update_entity_states, + self._async_handle_interval_callback, self.scan_interval, name=f"EntityPlatform poll {self.domain}.{self.platform_name}", ) + @callback + def _async_handle_interval_callback(self, now: datetime) -> None: + """Update all the entity states in a single platform.""" + self.hass.async_create_task(self._update_entity_states(now), eager_start=True) + def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: """Check if an entity_id already exists. @@ -791,9 +874,17 @@ async def async_reset(self) -> None: if not self.entities: return - tasks = [entity.async_remove() for entity in self.entities.values()] - - await asyncio.gather(*tasks) + # Removals are awaited in series since in most + # cases calling async_remove will not yield control + # to the event loop and we want to avoid scheduling + # one task per entity. + for entity in list(self.entities.values()): + try: + await entity.async_remove() + except Exception: # pylint: disable=broad-except + self.logger.exception( + "Error while removing entity %s", entity.entity_id + ) self.async_unsub_polling() self._setup_complete = False @@ -912,7 +1003,7 @@ async def _update_entity_states(self, now: datetime) -> None: return if tasks := [ - entity.async_update_ha_state(True) + create_eager_task(entity.async_update_ha_state(True)) for entity in self.entities.values() if entity.should_poll ]: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b6790ff0dc3e4f..50ecbc1fb59486 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,7 +65,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 12 +STORAGE_VERSION_MINOR = 13 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -135,6 +135,7 @@ class _EventEntityRegistryUpdatedData_Update(TypedDict): DISPLAY_DICT_OPTIONAL = ( ("ai", "area_id"), + ("lb", "labels"), ("di", "device_id"), ("ic", "icon"), ("tk", "translation_key"), @@ -174,6 +175,7 @@ class RegistryEntry: converter=attr.converters.default_if_none(factory=uuid_util.random_uuid_hex), # type: ignore[misc] ) has_entity_name: bool = attr.ib(default=False) + labels: set[str] = attr.ib(factory=set) name: str | None = attr.ib(default=None) options: ReadOnlyEntityOptionsType = attr.ib( default=None, converter=_protect_entity_options @@ -219,9 +221,7 @@ def _as_display_dict(self) -> dict[str, Any] | None: if not self.name and self.has_entity_name: display_dict["en"] = self.original_name if self.domain == "sensor" and (sensor_options := self.options.get("sensor")): - if (precision := sensor_options.get("display_precision")) is not None: - display_dict["dp"] = precision - elif ( + if (precision := sensor_options.get("display_precision")) is not None or ( precision := sensor_options.get("suggested_display_precision") ) is not None: display_dict["dp"] = precision @@ -262,6 +262,7 @@ def as_partial_dict(self) -> dict[str, Any]: "hidden_by": self.hidden_by, "icon": self.icon, "id": self.id, + "labels": self.labels, "name": self.name, "options": self.options, "original_name": self.original_name, @@ -348,7 +349,7 @@ def _domain_default(self) -> str: class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" - async def _async_migrate_func( + async def _async_migrate_func( # noqa: C901 self, old_major_version: int, old_minor_version: int, @@ -429,6 +430,11 @@ async def _async_migrate_func( for entity in data["entities"]: entity["previous_unique_id"] = None + if old_major_version == 1 and old_minor_version < 13: + # Version 1.13 adds labels + for entity in data["entities"]: + entity["labels"] = [] + if old_major_version > 1: raise NotImplementedError return data @@ -449,8 +455,9 @@ def __init__(self) -> None: super().__init__() self._entry_ids: dict[str, RegistryEntry] = {} self._index: dict[tuple[str, str, str], str] = {} - self._config_entry_id_index: dict[str, list[str]] = {} - self._device_id_index: dict[str, list[str]] = {} + self._config_entry_id_index: dict[str, dict[str, Literal[True]]] = {} + self._device_id_index: dict[str, dict[str, Literal[True]]] = {} + self._area_id_index: dict[str, dict[str, Literal[True]]] = {} def values(self) -> ValuesView[RegistryEntry]: """Return the underlying values to avoid __iter__ overhead.""" @@ -464,26 +471,40 @@ def __setitem__(self, key: str, entry: RegistryEntry) -> None: data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id + # python has no ordered set, so we use a dict with True values + # https://discuss.python.org/t/add-orderedset-to-stdlib/12730 if (config_entry_id := entry.config_entry_id) is not None: - self._config_entry_id_index.setdefault(config_entry_id, []).append(key) + self._config_entry_id_index.setdefault(config_entry_id, {})[key] = True if (device_id := entry.device_id) is not None: - self._device_id_index.setdefault(device_id, []).append(key) + self._device_id_index.setdefault(device_id, {})[key] = True + if (area_id := entry.area_id) is not None: + self._area_id_index.setdefault(area_id, {})[key] = True + + def _unindex_entry_value( + self, key: str, value: str, index: dict[str, dict[str, Literal[True]]] + ) -> None: + """Unindex an entry value. + + key is the entry key + value is the value to unindex such as config_entry_id or device_id. + index is the index to unindex from. + """ + entries = index[value] + del entries[key] + if not entries: + del index[value] def _unindex_entry(self, key: str) -> None: """Unindex an entry.""" entry = self.data[key] del self._entry_ids[entry.id] del self._index[(entry.domain, entry.platform, entry.unique_id)] - if (config_entry_id := entry.config_entry_id) is not None: - entries = self._config_entry_id_index[config_entry_id] - entries.remove(key) - if not entries: - del self._config_entry_id_index[config_entry_id] - if (device_id := entry.device_id) is not None: - entries = self._device_id_index[device_id] - entries.remove(key) - if not entries: - del self._device_id_index[device_id] + if config_entry_id := entry.config_entry_id: + self._unindex_entry_value(key, config_entry_id, self._config_entry_id_index) + if device_id := entry.device_id: + self._unindex_entry_value(key, device_id, self._device_id_index) + if area_id := entry.area_id: + self._unindex_entry_value(key, area_id, self._area_id_index) def __delitem__(self, key: str) -> None: """Remove an item.""" @@ -498,19 +519,31 @@ def get_entry(self, key: str) -> RegistryEntry | None: """Get entry from id.""" return self._entry_ids.get(key) - def get_entries_for_device_id(self, device_id: str) -> list[RegistryEntry]: + def get_entries_for_device_id( + self, device_id: str, include_disabled_entities: bool = False + ) -> list[RegistryEntry]: """Get entries for device.""" - return [self.data[key] for key in self._device_id_index.get(device_id, ())] + data = self.data + return [ + entry + for key in self._device_id_index.get(device_id, ()) + if not (entry := data[key]).disabled_by or include_disabled_entities + ] def get_entries_for_config_entry_id( self, config_entry_id: str ) -> list[RegistryEntry]: """Get entries for config entry.""" + data = self.data return [ - self.data[key] - for key in self._config_entry_id_index.get(config_entry_id, ()) + data[key] for key in self._config_entry_id_index.get(config_entry_id, ()) ] + def get_entries_for_area_id(self, area_id: str) -> list[RegistryEntry]: + """Get entries for area.""" + data = self.data + return [data[key] for key in self._area_id_index.get(area_id, ())] + class EntityRegistry: """Class to hold a registry of entities.""" @@ -856,6 +889,7 @@ def _async_update_entity( hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -906,6 +940,7 @@ def _async_update_entity( ("hidden_by", hidden_by), ("icon", icon), ("has_entity_name", has_entity_name), + ("labels", labels), ("name", name), ("options", options), ("original_device_class", original_device_class), @@ -983,6 +1018,7 @@ def async_update_entity( hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, has_entity_name: bool | UndefinedType = UNDEFINED, + labels: set[str] | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -1007,6 +1043,7 @@ def async_update_entity( hidden_by=hidden_by, icon=icon, has_entity_name=has_entity_name, + labels=labels, name=name, new_entity_id=new_entity_id, new_unique_id=new_unique_id, @@ -1104,6 +1141,7 @@ async def async_load(self) -> None: icon=entity["icon"], id=entity["id"], has_entity_name=entity["has_entity_name"], + labels=set(entity["labels"]), name=entity["name"], options=entity["options"], original_device_class=entity["original_device_class"], @@ -1160,6 +1198,7 @@ def _data_to_save(self) -> dict[str, Any]: "icon": entry.icon, "id": entry.id, "has_entity_name": entry.has_entity_name, + "labels": list(entry.labels), "name": entry.name, "options": entry.options, "original_device_class": entry.original_device_class, @@ -1188,14 +1227,22 @@ def _data_to_save(self) -> dict[str, Any]: return data + @callback + def async_clear_label_id(self, label_id: str) -> None: + """Clear label from registry entries.""" + for entity_id, entry in self.entities.items(): + if label_id in entry.labels: + labels = entry.labels.copy() + labels.remove(label_id) + self.async_update_entity(entity_id, labels=labels) + @callback def async_clear_config_entry(self, config_entry_id: str) -> None: """Clear config entry from registry entries.""" now_time = time.time() for entity_id in [ - entity_id - for entity_id, entry in self.entities.items() - if config_entry_id == entry.config_entry_id + entry.entity_id + for entry in self.entities.get_entries_for_config_entry_id(config_entry_id) ]: self.async_remove(entity_id) for key, deleted_entity in list(self.deleted_entities.items()): @@ -1226,9 +1273,8 @@ def async_purge_expired_orphaned_entities(self) -> None: @callback def async_clear_area_id(self, area_id: str) -> None: """Clear area id from registry entries.""" - for entity_id, entry in self.entities.items(): - if area_id == entry.area_id: - self.async_update_entity(entity_id, area_id=None) + for entry in self.entities.get_entries_for_area_id(area_id): + self.async_update_entity(entry.entity_id, area_id=None) @callback @@ -1249,11 +1295,9 @@ def async_entries_for_device( registry: EntityRegistry, device_id: str, include_disabled_entities: bool = False ) -> list[RegistryEntry]: """Return entries that match a device.""" - return [ - entry - for entry in registry.entities.get_entries_for_device_id(device_id) - if (not entry.disabled_by or include_disabled_entities) - ] + return registry.entities.get_entries_for_device_id( + device_id, include_disabled_entities + ) @callback @@ -1261,7 +1305,15 @@ def async_entries_for_area( registry: EntityRegistry, area_id: str ) -> list[RegistryEntry]: """Return entries that match an area.""" - return [entry for entry in registry.entities.values() if entry.area_id == area_id] + return registry.entities.get_entries_for_area_id(area_id) + + +@callback +def async_entries_for_label( + registry: EntityRegistry, label_id: str +) -> list[RegistryEntry]: + """Return entries that match a label.""" + return [entry for entry in registry.entities.values() if label_id in entry.labels] @callback @@ -1305,7 +1357,26 @@ def async_config_entry_disabled_by_changed( @callback def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None: """Clean up device registry when entities removed.""" - from . import event # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from . import event, label_registry as lr + + @callback + def _label_removed_from_registry_filter( + event: lr.EventLabelRegistryUpdated, + ) -> bool: + """Filter all except for the remove action from label registry events.""" + return event.data["action"] == "remove" + + @callback + def _handle_label_registry_update(event: lr.EventLabelRegistryUpdated) -> None: + """Update entity that have a label that has been removed.""" + registry.async_clear_label_id(event.data["label_id"]) + + hass.bus.async_listen( + event_type=lr.EVENT_LABEL_REGISTRY_UPDATED, + event_filter=_label_removed_from_registry_filter, # type: ignore[arg-type] + listener=_handle_label_registry_update, # type: ignore[arg-type] + ) @callback def cleanup(_: datetime) -> None: @@ -1379,16 +1450,12 @@ async def async_migrate_entries( Can also be used to remove duplicated entity registry entries. """ ent_reg = async_get(hass) - - for entry in list(ent_reg.entities.values()): - if entry.config_entry_id != config_entry_id: - continue - if not ent_reg.entities.get_entry(entry.id): - continue - - updates = entry_callback(entry) - - if updates is not None: + entities = ent_reg.entities + for entry in entities.get_entries_for_config_entry_id(config_entry_id): + if ( + entities.get_entry(entry.id) + and (updates := entry_callback(entry)) is not None + ): ent_reg.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index d3f4144a293a03..0dc3115466a466 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -10,7 +10,15 @@ import logging from random import randint import time -from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypedDict, TypeVar +from typing import ( + TYPE_CHECKING, + Any, + Concatenate, + Generic, + ParamSpec, + TypedDict, + TypeVar, +) import attr @@ -80,6 +88,32 @@ _P = ParamSpec("_P") +@dataclass(slots=True, frozen=True) +class _KeyedEventTracker(Generic[_TypedDictT]): + """Class to track events by key.""" + + listeners_key: str + callbacks_key: str + event_type: str + dispatcher_callable: Callable[ + [ + HomeAssistant, + dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], + EventType[_TypedDictT], + ], + None, + ] + filter_callable: Callable[ + [ + HomeAssistant, + dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], + EventType[_TypedDictT], + ], + bool, + ] + run_immediately: bool + + @dataclass(slots=True) class TrackStates: """Class for keeping track of states being tracked. @@ -291,7 +325,7 @@ def _async_dispatch_entity_id_event( """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["entity_id"])): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -312,6 +346,16 @@ def _async_state_change_filter( return event.data["entity_id"] in callbacks +_KEYED_TRACK_STATE_CHANGE = _KeyedEventTracker( + listeners_key=TRACK_STATE_CHANGE_LISTENER, + callbacks_key=TRACK_STATE_CHANGE_CALLBACKS, + event_type=EVENT_STATE_CHANGED, + dispatcher_callable=_async_dispatch_entity_id_event, + filter_callable=_async_state_change_filter, + run_immediately=False, +) + + @bind_hass def _async_track_state_change_event( hass: HomeAssistant, @@ -319,16 +363,7 @@ def _async_track_state_change_event( action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" - return _async_track_event( - hass, - entity_ids, - TRACK_STATE_CHANGE_CALLBACKS, - TRACK_STATE_CHANGE_LISTENER, - EVENT_STATE_CHANGED, - _async_dispatch_entity_id_event, - _async_state_change_filter, - action, - ) + return _async_track_event(_KEYED_TRACK_STATE_CHANGE, hass, entity_ids, action) @callback @@ -355,31 +390,22 @@ def _remove_listener( del hass.data[listeners_key] +# tracker, not hass is intentionally the first argument here since its +# constant and may be used in a partial in the future def _async_track_event( + tracker: _KeyedEventTracker[_TypedDictT], hass: HomeAssistant, keys: str | Iterable[str], - callbacks_key: str, - listeners_key: str, - event_type: str, - dispatcher_callable: Callable[ - [ - HomeAssistant, - dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], - EventType[_TypedDictT], - ], - None, - ], - filter_callable: Callable[ - [ - HomeAssistant, - dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], - EventType[_TypedDictT], - ], - bool, - ], action: Callable[[EventType[_TypedDictT]], None], ) -> CALLBACK_TYPE: - """Track an event by a specific key.""" + """Track an event by a specific key. + + This function is intended for internal use only. + + The dispatcher_callable, filter_callable, event_type, and run_immediately + must always be the same for the listener_key as the first call to this + function will set the listener_key in hass.data. + """ if not keys: return _remove_empty_listener @@ -387,25 +413,26 @@ def _async_track_event( keys = [keys] hass_data = hass.data + callbacks_key = tracker.callbacks_key - callbacks: dict[ - str, list[HassJob[[EventType[_TypedDictT]], Any]] - ] | None = hass_data.get(callbacks_key) - if not callbacks: + callbacks: dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]] | None + if not (callbacks := hass_data.get(callbacks_key)): callbacks = hass_data[callbacks_key] = {} + listeners_key = tracker.listeners_key + if listeners_key not in hass_data: hass_data[listeners_key] = hass.bus.async_listen( - event_type, - ft.partial(dispatcher_callable, hass, callbacks), - event_filter=ft.partial(filter_callable, hass, callbacks), + tracker.event_type, + ft.partial(tracker.dispatcher_callable, hass, callbacks), + event_filter=ft.partial(tracker.filter_callable, hass, callbacks), + run_immediately=tracker.run_immediately, ) - job = HassJob(action, f"track {event_type} event {keys}") + job = HassJob(action, f"track {tracker.event_type} event {keys}") for key in keys: - callback_list = callbacks.get(key) - if callback_list: + if callback_list := callbacks.get(key): callback_list.append(job) else: callbacks[key] = [job] @@ -428,7 +455,7 @@ def _async_dispatch_old_entity_id_or_entity_id_event( ) ): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -451,6 +478,16 @@ def _async_entity_registry_updated_filter( return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks +_KEYED_TRACK_ENTITY_REGISTRY_UPDATED = _KeyedEventTracker( + listeners_key=TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, + callbacks_key=TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + event_type=EVENT_ENTITY_REGISTRY_UPDATED, + dispatcher_callable=_async_dispatch_old_entity_id_or_entity_id_event, + filter_callable=_async_entity_registry_updated_filter, + run_immediately=True, +) + + @bind_hass @callback def async_track_entity_registry_updated_event( @@ -465,13 +502,9 @@ def async_track_entity_registry_updated_event( Similar to async_track_state_change_event. """ return _async_track_event( + _KEYED_TRACK_ENTITY_REGISTRY_UPDATED, hass, entity_ids, - TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, - TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, - EVENT_ENTITY_REGISTRY_UPDATED, - _async_dispatch_old_entity_id_or_entity_id_event, - _async_entity_registry_updated_filter, action, ) @@ -499,7 +532,7 @@ def _async_dispatch_device_id_event( """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["device_id"])): return - for job in callbacks_list[:]: + for job in callbacks_list.copy(): try: hass.async_run_hass_job(job, event) except Exception: # pylint: disable=broad-except @@ -510,6 +543,16 @@ def _async_dispatch_device_id_event( ) +_KEYED_TRACK_DEVICE_REGISTRY_UPDATED = _KeyedEventTracker( + listeners_key=TRACK_DEVICE_REGISTRY_UPDATED_LISTENER, + callbacks_key=TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS, + event_type=EVENT_DEVICE_REGISTRY_UPDATED, + dispatcher_callable=_async_dispatch_device_id_event, + filter_callable=_async_device_registry_updated_filter, + run_immediately=True, +) + + @callback def async_track_device_registry_updated_event( hass: HomeAssistant, @@ -521,13 +564,9 @@ def async_track_device_registry_updated_event( Similar to async_track_entity_registry_updated_event. """ return _async_track_event( + _KEYED_TRACK_DEVICE_REGISTRY_UPDATED, hass, device_ids, - TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS, - TRACK_DEVICE_REGISTRY_UPDATED_LISTENER, - EVENT_DEVICE_REGISTRY_UPDATED, - _async_dispatch_device_id_event, - _async_device_registry_updated_filter, action, ) @@ -574,6 +613,16 @@ def async_track_state_added_domain( return _async_track_state_added_domain(hass, domains, action) +_KEYED_TRACK_STATE_ADDED_DOMAIN = _KeyedEventTracker( + listeners_key=TRACK_STATE_ADDED_DOMAIN_LISTENER, + callbacks_key=TRACK_STATE_ADDED_DOMAIN_CALLBACKS, + event_type=EVENT_STATE_CHANGED, + dispatcher_callable=_async_dispatch_domain_event, + filter_callable=_async_domain_added_filter, + run_immediately=False, +) + + @bind_hass def _async_track_state_added_domain( hass: HomeAssistant, @@ -581,16 +630,7 @@ def _async_track_state_added_domain( action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is added to domains.""" - return _async_track_event( - hass, - domains, - TRACK_STATE_ADDED_DOMAIN_CALLBACKS, - TRACK_STATE_ADDED_DOMAIN_LISTENER, - EVENT_STATE_CHANGED, - _async_dispatch_domain_event, - _async_domain_added_filter, - action, - ) + return _async_track_event(_KEYED_TRACK_STATE_ADDED_DOMAIN, hass, domains, action) @callback @@ -606,6 +646,16 @@ def _async_domain_removed_filter( ) +_KEYED_TRACK_STATE_REMOVED_DOMAIN = _KeyedEventTracker( + listeners_key=TRACK_STATE_REMOVED_DOMAIN_LISTENER, + callbacks_key=TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, + event_type=EVENT_STATE_CHANGED, + dispatcher_callable=_async_dispatch_domain_event, + filter_callable=_async_domain_removed_filter, + run_immediately=False, +) + + @bind_hass def async_track_state_removed_domain( hass: HomeAssistant, @@ -613,16 +663,7 @@ def async_track_state_removed_domain( action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is removed from domains.""" - return _async_track_event( - hass, - domains, - TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, - TRACK_STATE_REMOVED_DOMAIN_LISTENER, - EVENT_STATE_CHANGED, - _async_dispatch_domain_event, - _async_domain_removed_filter, - action, - ) + return _async_track_event(_KEYED_TRACK_STATE_REMOVED_DOMAIN, hass, domains, action) @callback @@ -1106,6 +1147,24 @@ def _super_template_as_boolean(result: bool | str | TemplateError) -> bool: return result_as_boolean(result) + @callback + def _apply_update( + self, + updates: list[TrackTemplateResult], + update: bool | TrackTemplateResult, + template: Template, + ) -> bool: + """Handle updates of a tracked template.""" + if not update: + return False + + self._setup_time_listener(template, self._info[template].has_time) + + if isinstance(update, TrackTemplateResult): + updates.append(update) + + return True + @callback def _refresh( self, @@ -1129,20 +1188,6 @@ def _refresh( info_changed = False now = event.time_fired if not replayed and event else dt_util.utcnow() - def _apply_update( - update: bool | TrackTemplateResult, template: Template - ) -> bool: - """Handle updates of a tracked template.""" - if not update: - return False - - self._setup_time_listener(template, self._info[template].has_time) - - if isinstance(update, TrackTemplateResult): - updates.append(update) - - return True - block_updates = False super_template = self._track_templates[0] if self._has_super_template else None @@ -1151,7 +1196,7 @@ def _apply_update( # Update the super template first if super_template is not None: update = self._render_template_if_ready(super_template, now, event) - info_changed |= _apply_update(update, super_template.template) + info_changed |= self._apply_update(updates, update, super_template.template) if isinstance(update, TrackTemplateResult): super_result = update.result @@ -1182,7 +1227,9 @@ def _apply_update( continue update = self._render_template_if_ready(track_template_, now, event) - info_changed |= _apply_update(update, track_template_.template) + info_changed |= self._apply_update( + updates, update, track_template_.template + ) if info_changed: assert self._track_state_changes @@ -1442,7 +1489,7 @@ def async_track_point_in_utc_time( """ # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) - expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) + expected_fire_timestamp = utc_point_in_time.timestamp() job = ( action if isinstance(action, HassJob) diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py new file mode 100644 index 00000000000000..1149bbd1729f4f --- /dev/null +++ b/homeassistant/helpers/floor_registry.py @@ -0,0 +1,293 @@ +"""Provide a way to assign areas to floors in one's home.""" +from __future__ import annotations + +from collections import UserDict +from collections.abc import Iterable, ValuesView +import dataclasses +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, TypedDict, cast + +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .storage import Store +from .typing import UNDEFINED, EventType, UndefinedType + +DATA_REGISTRY = "floor_registry" +EVENT_FLOOR_REGISTRY_UPDATED = "floor_registry_updated" +STORAGE_KEY = "core.floor_registry" +STORAGE_VERSION_MAJOR = 1 +SAVE_DELAY = 10 + + +class EventFloorRegistryUpdatedData(TypedDict): + """Event data for when the floor registry is updated.""" + + action: Literal["create", "remove", "update"] + floor_id: str + + +EventFloorRegistryUpdated = EventType[EventFloorRegistryUpdatedData] + + +@dataclass(slots=True, kw_only=True, frozen=True) +class FloorEntry: + """Floor registry entry.""" + + aliases: set[str] + floor_id: str + icon: str | None = None + level: int = 0 + name: str + normalized_name: str + + +class FloorRegistryItems(UserDict[str, FloorEntry]): + """Container for floor registry items, maps floor id -> entry. + + Maintains an additional index: + - normalized name -> entry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._normalized_names: dict[str, FloorEntry] = {} + + def values(self) -> ValuesView[FloorEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: FloorEntry) -> None: + """Add an item.""" + data = self.data + normalized_name = _normalize_floor_name(entry.name) + + if key in data: + old_entry = data[key] + if ( + normalized_name != old_entry.normalized_name + and normalized_name in self._normalized_names + ): + raise ValueError( + f"The name {entry.name} ({normalized_name}) is already in use" + ) + del self._normalized_names[old_entry.normalized_name] + data[key] = entry + self._normalized_names[normalized_name] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + normalized_name = _normalize_floor_name(entry.name) + del self._normalized_names[normalized_name] + super().__delitem__(key) + + def get_floor_by_name(self, name: str) -> FloorEntry | None: + """Get floor by name.""" + return self._normalized_names.get(_normalize_floor_name(name)) + + +class FloorRegistry: + """Class to hold a registry of floors.""" + + floors: FloorRegistryItems + _floor_data: dict[str, FloorEntry] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the floor registry.""" + self.hass = hass + self._store: Store[ + dict[str, list[dict[str, str | int | list[str] | None]]] + ] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + ) + + @callback + def async_get_floor(self, floor_id: str) -> FloorEntry | None: + """Get floor by id. + + We retrieve the FloorEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._floor_data.get(floor_id) + + @callback + def async_get_floor_by_name(self, name: str) -> FloorEntry | None: + """Get floor by name.""" + return self.floors.get_floor_by_name(name) + + @callback + def async_list_floors(self) -> Iterable[FloorEntry]: + """Get all floors.""" + return self.floors.values() + + @callback + def _generate_id(self, name: str) -> str: + """Generate floor ID.""" + suggestion = suggestion_base = slugify(name) + tries = 1 + while suggestion in self.floors: + tries += 1 + suggestion = f"{suggestion_base}_{tries}" + return suggestion + + @callback + def async_create( + self, + name: str, + *, + aliases: set[str] | None = None, + icon: str | None = None, + level: int = 0, + ) -> FloorEntry: + """Create a new floor.""" + if floor := self.async_get_floor_by_name(name): + raise ValueError( + f"The name {name} ({floor.normalized_name}) is already in use" + ) + + normalized_name = _normalize_floor_name(name) + + floor = FloorEntry( + aliases=aliases or set(), + icon=icon, + floor_id=self._generate_id(name), + name=name, + normalized_name=normalized_name, + level=level, + ) + floor_id = floor.floor_id + self.floors[floor_id] = floor + self.async_schedule_save() + self.hass.bus.async_fire( + EVENT_FLOOR_REGISTRY_UPDATED, + EventFloorRegistryUpdatedData( + action="create", + floor_id=floor_id, + ), + ) + return floor + + @callback + def async_delete(self, floor_id: str) -> None: + """Delete floor.""" + del self.floors[floor_id] + self.hass.bus.async_fire( + EVENT_FLOOR_REGISTRY_UPDATED, + EventFloorRegistryUpdatedData( + action="remove", + floor_id=floor_id, + ), + ) + self.async_schedule_save() + + @callback + def async_update( + self, + floor_id: str, + *, + aliases: set[str] | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, + level: int | UndefinedType = UNDEFINED, + name: str | UndefinedType = UNDEFINED, + ) -> FloorEntry: + """Update name of the floor.""" + old = self.floors[floor_id] + changes = { + attr_name: value + for attr_name, value in ( + ("aliases", aliases), + ("icon", icon), + ("level", level), + ) + if value is not UNDEFINED and value != getattr(old, attr_name) + } + if name is not UNDEFINED and name != old.name: + changes["name"] = name + changes["normalized_name"] = _normalize_floor_name(name) + + if not changes: + return old + + new = self.floors[floor_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] + + self.async_schedule_save() + self.hass.bus.async_fire( + EVENT_FLOOR_REGISTRY_UPDATED, + EventFloorRegistryUpdatedData( + action="update", + floor_id=floor_id, + ), + ) + + return new + + async def async_load(self) -> None: + """Load the floor registry.""" + data = await self._store.async_load() + floors = FloorRegistryItems() + + if data is not None: + for floor in data["floors"]: + if TYPE_CHECKING: + assert isinstance(floor["aliases"], list) + assert isinstance(floor["icon"], str) + assert isinstance(floor["level"], int) + assert isinstance(floor["name"], str) + assert isinstance(floor["floor_id"], str) + + normalized_name = _normalize_floor_name(floor["name"]) + floors[floor["floor_id"]] = FloorEntry( + aliases=set(floor["aliases"]), + icon=floor["icon"], + floor_id=floor["floor_id"], + name=floor["name"], + level=floor["level"], + normalized_name=normalized_name, + ) + + self.floors = floors + self._floor_data = floors.data + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the floor registry.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | int | list[str] | None]]]: + """Return data of floor registry to store in a file.""" + return { + "floors": [ + { + "aliases": list(entry.aliases), + "floor_id": entry.floor_id, + "icon": entry.icon, + "level": entry.level, + "name": entry.name, + } + for entry in self.floors.values() + ] + } + + +@callback +def async_get(hass: HomeAssistant) -> FloorRegistry: + """Get floor registry.""" + return cast(FloorRegistry, hass.data[DATA_REGISTRY]) + + +async def async_load(hass: HomeAssistant) -> None: + """Load floor registry.""" + assert DATA_REGISTRY not in hass.data + hass.data[DATA_REGISTRY] = FloorRegistry(hass) + await hass.data[DATA_REGISTRY].async_load() + + +def _normalize_floor_name(floor_name: str) -> str: + """Normalize a floor name by removing whitespace and case folding.""" + return floor_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 920c7150f6dbd4..04f16ebddd0c11 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -34,6 +34,26 @@ class IntegrationFrame: relative_filename: str +def get_integration_logger(fallback_name: str) -> logging.Logger: + """Return a logger by checking the current integration frame. + + If Python is unable to access the sources files, the call stack frame + will be missing information, so let's guard by requiring a fallback name. + https://github.com/home-assistant/core/issues/24982 + """ + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + return logging.getLogger(fallback_name) + + if integration_frame.custom_integration: + logger_name = f"custom_components.{integration_frame.integration}" + else: + logger_name = f"homeassistant.components.{integration_frame.integration}" + + return logging.getLogger(logger_name) + + def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: """Return the frame, integration and integration path of the current stack frame.""" found_frame = None @@ -86,6 +106,7 @@ def report( exclude_integrations: set | None = None, error_if_core: bool = True, level: int = logging.WARNING, + log_custom_component_only: bool = False, ) -> None: """Report incorrect usage. @@ -99,10 +120,12 @@ def report( msg = f"Detected code that {what}. Please report this issue." if error_if_core: raise RuntimeError(msg) from err - _LOGGER.warning(msg, stack_info=True) + if not log_custom_component_only: + _LOGGER.warning(msg, stack_info=True) return - _report_integration(what, integration_frame, level) + if not log_custom_component_only or integration_frame.custom_integration: + _report_integration(what, integration_frame, level) def _report_integration( diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py new file mode 100644 index 00000000000000..63ff173a3a0266 --- /dev/null +++ b/homeassistant/helpers/http.py @@ -0,0 +1,184 @@ +"""Helper to track the current http request.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from contextvars import ContextVar +from http import HTTPStatus +import logging +from typing import Any, Final + +from aiohttp import web +from aiohttp.typedefs import LooseHeaders +from aiohttp.web import Request +from aiohttp.web_exceptions import ( + HTTPBadRequest, + HTTPInternalServerError, + HTTPUnauthorized, +) +from aiohttp.web_urldispatcher import AbstractRoute +import voluptuous as vol + +from homeassistant import exceptions +from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.core import Context, HomeAssistant, is_callback +from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data + +from .json import find_paths_unserializable_data, json_bytes, json_dumps + +_LOGGER = logging.getLogger(__name__) + + +KEY_AUTHENTICATED: Final = "ha_authenticated" + +current_request: ContextVar[Request | None] = ContextVar( + "current_request", default=None +) + + +def request_handler_factory( + hass: HomeAssistant, view: HomeAssistantView, handler: Callable +) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: + """Wrap the handler classes.""" + is_coroutinefunction = asyncio.iscoroutinefunction(handler) + assert is_coroutinefunction or is_callback( + handler + ), "Handler should be a coroutine or a callback." + + async def handle(request: web.Request) -> web.StreamResponse: + """Handle incoming request.""" + if hass.is_stopping: + return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.remote, + authenticated, + ) + + try: + if is_coroutinefunction: + result = await handler(request, **request.match_info) + else: + result = handler(request, **request.match_info) + except vol.Invalid as err: + raise HTTPBadRequest() from err + except exceptions.ServiceNotFound as err: + raise HTTPInternalServerError() from err + except exceptions.Unauthorized as err: + raise HTTPUnauthorized() from err + + if isinstance(result, web.StreamResponse): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = HTTPStatus.OK + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, bytes): + return web.Response(body=result, status=status_code) + + if isinstance(result, str): + return web.Response(text=result, status=status_code) + + if result is None: + return web.Response(body=b"", status=status_code) + + raise TypeError( + f"Result should be None, string, bytes or StreamResponse. Got: {result}" + ) + + return handle + + +class HomeAssistantView: + """Base view for all views.""" + + url: str | None = None + extra_urls: list[str] = [] + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False + + @staticmethod + def context(request: web.Request) -> Context: + """Generate a context from a request.""" + if (user := request.get("hass_user")) is None: + return Context() + + return Context(user_id=user.id) + + @staticmethod + def json( + result: Any, + status_code: HTTPStatus | int = HTTPStatus.OK, + headers: LooseHeaders | None = None, + ) -> web.Response: + """Return a JSON response.""" + try: + msg = json_bytes(result) + except JSON_ENCODE_EXCEPTIONS as err: + _LOGGER.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(result, dump=json_dumps) + ), + ) + raise HTTPInternalServerError from err + response = web.Response( + body=msg, + content_type=CONTENT_TYPE_JSON, + status=int(status_code), + headers=headers, + zlib_executor_size=32768, + ) + response.enable_compression() + return response + + def json_message( + self, + message: str, + status_code: HTTPStatus | int = HTTPStatus.OK, + message_code: str | None = None, + headers: LooseHeaders | None = None, + ) -> web.Response: + """Return a JSON message response.""" + data = {"message": message} + if message_code is not None: + data["code"] = message_code + return self.json(data, status_code, headers=headers) + + def register( + self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher + ) -> None: + """Register the view with a router.""" + assert self.url is not None, "No url set for view" + urls = [self.url] + self.extra_urls + routes: list[AbstractRoute] = [] + + for method in ("get", "post", "delete", "put", "patch", "head", "options"): + if not (handler := getattr(self, method, None)): + continue + + handler = request_handler_factory(hass, self, handler) + + for url in urls: + routes.append(router.add_route(method, url, handler)) + + # Use `get` because CORS middleware is not be loaded in emulated_hue + if self.cors_allowed: + allow_cors = app.get("allow_all_cors") + else: + allow_cors = app.get("allow_configured_cors") + + if allow_cors: + for route in routes: + allow_cors(route) diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index 3486925b095347..f1638732527353 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -13,7 +13,6 @@ from .translation import build_resources -ICON_LOAD_LOCK = "icon_load_lock" ICON_CACHE = "icon_cache" _LOGGER = logging.getLogger(__name__) @@ -73,13 +72,14 @@ async def _async_get_component_icons( class _IconsCache: """Cache for icons.""" - __slots__ = ("_hass", "_loaded", "_cache") + __slots__ = ("_hass", "_loaded", "_cache", "_lock") def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self._hass = hass self._loaded: set[str] = set() self._cache: dict[str, dict[str, Any]] = {} + self._lock = asyncio.Lock() async def async_fetch( self, @@ -88,7 +88,13 @@ async def async_fetch( ) -> dict[str, dict[str, Any]]: """Load resources into the cache.""" if components_to_load := components - self._loaded: - await self._async_load(components_to_load) + # Icons are never unloaded so if there are no components to load + # we can skip the lock which reduces contention + async with self._lock: + # Check components to load again, as another task might have loaded + # them while we were waiting for the lock. + if components_to_load := components - self._loaded: + await self._async_load(components_to_load) return { component: result @@ -98,13 +104,10 @@ async def async_fetch( async def _async_load(self, components: set[str]) -> None: """Populate the cache for a given set of components.""" - _LOGGER.debug( - "Cache miss for: %s", - ", ".join(components), - ) + _LOGGER.debug("Cache miss for: %s", components) integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = {loaded.rpartition(".")[-1] for loaded in components} ints_or_excs = await async_get_integrations(self._hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): @@ -123,16 +126,15 @@ def _build_category_cache( icons: dict[str, dict[str, Any]], ) -> None: """Extract resources into the cache.""" - resource: dict[str, Any] | str categories: set[str] = set() + for resource in icons.values(): categories.update(resource) for category in categories: - new_resources = build_resources(icons, components, category) - for component, resource in new_resources.items(): - category_cache: dict[str, Any] = self._cache.setdefault(category, {}) - category_cache[component] = resource + self._cache.setdefault(category, {}).update( + build_resources(icons, components, category) + ) async def async_get_icons( @@ -143,21 +145,19 @@ async def async_get_icons( """Return all icons of integrations. If integration specified, load it for that one; otherwise default to loaded - intgrations. + integrations. """ - lock = hass.data.setdefault(ICON_LOAD_LOCK, asyncio.Lock()) - if integrations: components = set(integrations) else: components = { component for component in hass.config.components if "." not in component } - async with lock: - if ICON_CACHE in hass.data: - cache: _IconsCache = hass.data[ICON_CACHE] - else: - cache = hass.data[ICON_CACHE] = _IconsCache(hass) + + if ICON_CACHE in hass.data: + cache: _IconsCache = hass.data[ICON_CACHE] + else: + cache = hass.data[ICON_CACHE] = _IconsCache(hass) return await cache.async_fetch(category, components) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 0a9a6efd525648..138722bd455076 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -4,13 +4,23 @@ import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass +from functools import partial import logging +from types import ModuleType from typing import Any from homeassistant.const import EVENT_COMPONENT_LOADED -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.setup import ATTR_COMPONENT +from homeassistant.core import HassJob, HomeAssistant, callback +from homeassistant.loader import ( + Integration, + async_get_integrations, + async_get_loaded_integration, + bind_hass, +) +from homeassistant.setup import ATTR_COMPONENT, EventComponentLoaded +from homeassistant.util.logging import catch_log_exception + +from .typing import EventType _LOGGER = logging.getLogger(__name__) DATA_INTEGRATION_PLATFORMS = "integration_platforms" @@ -21,33 +31,25 @@ class IntegrationPlatform: """An integration platform.""" platform_name: str - process_platform: Callable[[HomeAssistant, str, Any], Awaitable[None]] + process_job: HassJob[[HomeAssistant, str, Any], Awaitable[None] | None] seen_components: set[str] -async def _async_process_single_integration_platform_component( - 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) - +@callback +def _get_platform( + integration: Integration | Exception, component_name: str, platform_name: str +) -> ModuleType | None: + """Get a platform from an integration.""" if isinstance(integration, Exception): _LOGGER.exception( "Error importing integration %s for %s", component_name, - integration_platform.platform_name, + platform_name, ) - return - - platform_name = integration_platform.platform_name + return None try: - platform = integration.get_platform(platform_name) + return integration.get_platform(platform_name) except ImportError as err: if f"{component_name}.{platform_name}" not in str(err): _LOGGER.exception( @@ -55,39 +57,38 @@ async def _async_process_single_integration_platform_component( component_name, platform_name, ) - return - try: - await integration_platform.process_platform(hass, component_name, platform) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error processing platform %s.%s", component_name, platform_name - ) + return None -async def _async_process_integration_platform_for_component( - hass: HomeAssistant, component_name: str +@callback +def _async_process_integration_platforms_for_component( + hass: HomeAssistant, + integration_platforms: list[IntegrationPlatform], + event: EventType[EventComponentLoaded], ) -> None: """Process integration platforms for a component.""" - integration_platforms: list[IntegrationPlatform] = hass.data[ - DATA_INTEGRATION_PLATFORMS - ] - integrations = await async_get_integrations(hass, (component_name,)) - tasks = [ - asyncio.create_task( - _async_process_single_integration_platform_component( - hass, - component_name, - integrations[component_name], - integration_platform, - ), - name=f"process integration platform {integration_platform.platform_name} for {component_name}", + component_name = event.data[ATTR_COMPONENT] + if "." in component_name: + return + + integration = async_get_loaded_integration(hass, component_name) + for integration_platform in integration_platforms: + if component_name in integration_platform.seen_components or not ( + platform := _get_platform( + integration, component_name, integration_platform.platform_name + ) + ): + continue + integration_platform.seen_components.add(component_name) + hass.async_run_hass_job( + integration_platform.process_job, hass, component_name, platform ) - for integration_platform in integration_platforms - if component_name not in integration_platform.seen_components - ] - if tasks: - await asyncio.gather(*tasks) + + +def _format_err(name: str, platform_name: str, *args: Any) -> str: + """Format error message.""" + return f"Exception in {name} when processing platform '{platform_name}': {args}" @bind_hass @@ -95,47 +96,44 @@ async def async_process_integration_platforms( hass: HomeAssistant, platform_name: str, # Any = platform. - process_platform: Callable[[HomeAssistant, str, Any], Awaitable[None]], + process_platform: Callable[[HomeAssistant, str, Any], Awaitable[None] | None], ) -> None: """Process a specific platform for all current and future loaded integrations.""" if DATA_INTEGRATION_PLATFORMS not in hass.data: - hass.data[DATA_INTEGRATION_PLATFORMS] = [] - - async def _async_component_loaded(event: Event) -> None: - """Handle a new component loaded.""" - await _async_process_integration_platform_for_component( - hass, event.data[ATTR_COMPONENT] - ) - - @callback - def _async_component_loaded_filter(event: Event) -> bool: - """Handle integration platforms loaded.""" - return "." not in event.data[ATTR_COMPONENT] - + integration_platforms: list[IntegrationPlatform] = [] + hass.data[DATA_INTEGRATION_PLATFORMS] = integration_platforms hass.bus.async_listen( EVENT_COMPONENT_LOADED, - _async_component_loaded, - event_filter=_async_component_loaded_filter, + partial( + _async_process_integration_platforms_for_component, + hass, + integration_platforms, + ), ) - - integration_platforms: list[IntegrationPlatform] = hass.data[ - DATA_INTEGRATION_PLATFORMS - ] - integration_platform = IntegrationPlatform(platform_name, process_platform, set()) + else: + integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] + + top_level_components = {comp for comp in hass.config.components if "." not in comp} + process_job = HassJob( + catch_log_exception( + process_platform, + partial(_format_err, str(process_platform), platform_name), + ), + f"process_platform {platform_name}", + ) + integration_platform = IntegrationPlatform( + platform_name, process_job, top_level_components + ) integration_platforms.append(integration_platform) - if top_level_components := [ - comp for comp in hass.config.components if "." not in comp + + if not top_level_components: + return + + integrations = await async_get_integrations(hass, top_level_components) + if futures := [ + future + for comp in top_level_components + if (platform := _get_platform(integrations[comp], comp, platform_name)) + and (future := hass.async_run_hass_job(process_job, hass, comp, platform)) ]: - integrations = await async_get_integrations(hass, top_level_components) - tasks = [ - asyncio.create_task( - _async_process_single_integration_platform_component( - 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) + await asyncio.gather(*futures) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 295246b5e0aa6d..82385f0cda8296 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -2,11 +2,13 @@ from __future__ import annotations +from abc import abstractmethod import asyncio from collections.abc import Collection, Coroutine, Iterable import dataclasses from dataclasses import dataclass from enum import Enum +from functools import cached_property import logging from typing import Any, TypeVar @@ -33,6 +35,7 @@ INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" INTENT_NEVERMIND = "HassNevermind" +INTENT_SET_POSITION = "HassSetPosition" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) @@ -347,7 +350,6 @@ class IntentHandler: intent_type: str | None = None slot_schema: vol.Schema | None = None - _slot_schema: vol.Schema | None = None platforms: Iterable[str] | None = [] @callback @@ -361,17 +363,20 @@ def async_validate_slots(self, slots: _SlotsType) -> _SlotsType: if self.slot_schema is None: return slots - if self._slot_schema is None: - self._slot_schema = vol.Schema( - { - key: SLOT_SCHEMA.extend({"value": validator}) - for key, validator in self.slot_schema.items() - }, - extra=vol.ALLOW_EXTRA, - ) - return self._slot_schema(slots) # type: ignore[no-any-return] + @cached_property + def _slot_schema(self) -> vol.Schema: + """Create validation schema for slots.""" + assert self.slot_schema is not None + return vol.Schema( + { + key: SLOT_SCHEMA.extend({"value": validator}) + for key, validator in self.slot_schema.items() + }, + extra=vol.ALLOW_EXTRA, + ) + async def async_handle(self, intent_obj: Intent) -> IntentResponse: """Handle the intent.""" raise NotImplementedError() @@ -381,8 +386,8 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} - {self.intent_type}>" -class ServiceIntentHandler(IntentHandler): - """Service Intent handler registration. +class DynamicServiceIntentHandler(IntentHandler): + """Service Intent handler registration (dynamic). Service specific intent handler that calls a service by name/entity_id. """ @@ -398,13 +403,47 @@ class ServiceIntentHandler(IntentHandler): service_timeout: float = 0.2 def __init__( - self, intent_type: str, domain: str, service: str, speech: str | None = None + self, + intent_type: str, + speech: str | None = None, + extra_slots: dict[str, vol.Schema] | None = None, ) -> None: """Create Service Intent Handler.""" self.intent_type = intent_type - self.domain = domain - self.service = service self.speech = speech + self.extra_slots = extra_slots + + @cached_property + def _slot_schema(self) -> vol.Schema: + """Create validation schema for slots (with extra required slots).""" + if self.slot_schema is None: + raise ValueError("Slot schema is not defined") + + if self.extra_slots: + slot_schema = { + **self.slot_schema, + **{ + vol.Required(key): schema + for key, schema in self.extra_slots.items() + }, + } + else: + slot_schema = self.slot_schema + + return vol.Schema( + { + key: SLOT_SCHEMA.extend({"value": validator}) + for key, validator in slot_schema.items() + }, + extra=vol.ALLOW_EXTRA, + ) + + @abstractmethod + def get_domain_and_service( + self, intent_obj: Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + raise NotImplementedError() async def async_handle(self, intent_obj: Intent) -> IntentResponse: """Handle the hass intent.""" @@ -467,6 +506,9 @@ async def async_handle(self, intent_obj: Intent) -> IntentResponse: area=area_name or area_id, ) + # Update intent slots to include any transformations done by the schemas + intent_obj.slots = slots + response = await self.async_handle_states(intent_obj, states, area) # Make the matched states available in the response @@ -498,7 +540,10 @@ async def async_handle_states( service_coros: list[Coroutine[Any, Any, None]] = [] for state in states: - service_coros.append(self.async_call_service(intent_obj, state)) + domain, service = self.get_domain_and_service(intent_obj, state) + service_coros.append( + self.async_call_service(domain, service, intent_obj, state) + ) # Handle service calls in parallel, noting failures as they occur. failed_results: list[IntentResponseTarget] = [] @@ -520,7 +565,7 @@ async def async_handle_states( # If no entities succeeded, raise an error. failed_entity_ids = [target.id for target in failed_results] raise IntentHandleError( - f"Failed to call {self.service} for: {failed_entity_ids}" + f"Failed to call {service} for: {failed_entity_ids}" ) response.async_set_results( @@ -536,19 +581,28 @@ async def async_handle_states( return response - async def async_call_service(self, intent_obj: Intent, state: State) -> None: + async def async_call_service( + self, domain: str, service: str, intent_obj: Intent, state: State + ) -> None: """Call service on entity.""" hass = intent_obj.hass + + service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id} + if self.extra_slots: + service_data.update( + {key: intent_obj.slots[key]["value"] for key in self.extra_slots} + ) + await self._run_then_background( hass.async_create_task( hass.services.async_call( - self.domain, - self.service, - {ATTR_ENTITY_ID: state.entity_id}, + domain, + service, + service_data, context=intent_obj.context, blocking=True, ), - f"intent_call_service_{self.domain}_{self.service}", + f"intent_call_service_{domain}_{service}", ) ) @@ -559,7 +613,7 @@ async def _run_then_background(self, task: asyncio.Task[Any]) -> None: """ try: await asyncio.wait({task}, timeout=self.service_timeout) - except asyncio.TimeoutError: + except TimeoutError: pass except asyncio.CancelledError: # Task calling us was cancelled, so cancel service call task, and wait for @@ -570,6 +624,32 @@ async def _run_then_background(self, task: asyncio.Task[Any]) -> None: raise +class ServiceIntentHandler(DynamicServiceIntentHandler): + """Service Intent handler registration. + + Service specific intent handler that calls a service by name/entity_id. + """ + + def __init__( + self, + intent_type: str, + domain: str, + service: str, + speech: str | None = None, + extra_slots: dict[str, vol.Schema] | None = None, + ) -> None: + """Create service handler.""" + super().__init__(intent_type, speech=speech, extra_slots=extra_slots) + self.domain = domain + self.service = service + + def get_domain_and_service( + self, intent_obj: Intent, state: State + ) -> tuple[str, str]: + """Get the domain and service name to call.""" + return (self.domain, self.service) + + class IntentCategory(Enum): """Category of an intent.""" diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index b9862907960bed..ba2486a196ece3 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -148,12 +148,17 @@ def json_dumps_sorted(data: Any) -> str: def _orjson_default_encoder(data: Any) -> str: - """JSON encoder that uses orjson with hass defaults.""" + """JSON encoder that uses orjson with hass defaults and returns a str.""" + return _orjson_bytes_default_encoder(data).decode("utf-8") + + +def _orjson_bytes_default_encoder(data: Any) -> bytes: + """JSON encoder that uses orjson with hass defaults and returns bytes.""" return orjson.dumps( data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS, default=json_encoder_default, - ).decode("utf-8") + ) def save_json( @@ -173,11 +178,13 @@ def save_json( if encoder and encoder is not JSONEncoder: # If they pass a custom encoder that is not the # default JSONEncoder, we use the slow path of json.dumps + mode = "w" dump = json.dumps - json_data = json.dumps(data, indent=2, cls=encoder) + json_data: str | bytes = json.dumps(data, indent=2, cls=encoder) else: + mode = "wb" dump = _orjson_default_encoder - json_data = _orjson_default_encoder(data) + json_data = _orjson_bytes_default_encoder(data) except TypeError as error: formatted_data = format_unserializable_data( find_paths_unserializable_data(data, dump=dump) @@ -186,10 +193,8 @@ def save_json( _LOGGER.error(msg) raise SerializationError(msg) from error - if atomic_writes: - write_utf8_file_atomic(filename, json_data, private) - else: - write_utf8_file(filename, json_data, private) + method = write_utf8_file_atomic if atomic_writes else write_utf8_file + method(filename, json_data, private, mode=mode) def find_paths_unserializable_data( diff --git a/homeassistant/helpers/label_registry.py b/homeassistant/helpers/label_registry.py new file mode 100644 index 00000000000000..9c7f20a651536a --- /dev/null +++ b/homeassistant/helpers/label_registry.py @@ -0,0 +1,289 @@ +"""Provide a way to label and group anything.""" +from __future__ import annotations + +from collections import UserDict +from collections.abc import Iterable, ValuesView +import dataclasses +from dataclasses import dataclass +from typing import Literal, TypedDict, cast + +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .storage import Store +from .typing import UNDEFINED, EventType, UndefinedType + +DATA_REGISTRY = "label_registry" +EVENT_LABEL_REGISTRY_UPDATED = "label_registry_updated" +STORAGE_KEY = "core.label_registry" +STORAGE_VERSION_MAJOR = 1 +SAVE_DELAY = 10 + + +class EventLabelRegistryUpdatedData(TypedDict): + """Event data for when the label registry is updated.""" + + action: Literal["create", "remove", "update"] + label_id: str + + +EventLabelRegistryUpdated = EventType[EventLabelRegistryUpdatedData] + + +@dataclass(slots=True, frozen=True) +class LabelEntry: + """Label Registry Entry.""" + + label_id: str + name: str + normalized_name: str + description: str | None = None + color: str | None = None + icon: str | None = None + + +class LabelRegistryItems(UserDict[str, LabelEntry]): + """Container for label registry items, maps label id -> entry. + + Maintains an additional index: + - normalized name -> entry + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._normalized_names: dict[str, LabelEntry] = {} + + def values(self) -> ValuesView[LabelEntry]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: LabelEntry) -> None: + """Add an item.""" + data = self.data + normalized_name = _normalize_label_name(entry.name) + + if key in data: + old_entry = data[key] + if ( + normalized_name != old_entry.normalized_name + and normalized_name in self._normalized_names + ): + raise ValueError( + f"The name {entry.name} ({normalized_name}) is already in use" + ) + del self._normalized_names[old_entry.normalized_name] + data[key] = entry + self._normalized_names[normalized_name] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + normalized_name = _normalize_label_name(entry.name) + del self._normalized_names[normalized_name] + super().__delitem__(key) + + def get_label_by_name(self, name: str) -> LabelEntry | None: + """Get label by name.""" + return self._normalized_names.get(_normalize_label_name(name)) + + +class LabelRegistry: + """Class to hold a registry of labels.""" + + labels: LabelRegistryItems + _label_data: dict[str, LabelEntry] + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the label registry.""" + self.hass = hass + self._store: Store[dict[str, list[dict[str, str | None]]]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + ) + + @callback + def async_get_label(self, label_id: str) -> LabelEntry | None: + """Get label by ID. + + We retrieve the LabelEntry from the underlying dict to avoid + the overhead of the UserDict __getitem__. + """ + return self._label_data.get(label_id) + + @callback + def async_get_label_by_name(self, name: str) -> LabelEntry | None: + """Get label by name.""" + return self.labels.get_label_by_name(name) + + @callback + def async_list_labels(self) -> Iterable[LabelEntry]: + """Get all labels.""" + return self.labels.values() + + @callback + def _generate_id(self, name: str) -> str: + """Initialize ID.""" + suggestion = suggestion_base = slugify(name) + tries = 1 + while suggestion in self.labels: + tries += 1 + suggestion = f"{suggestion_base}_{tries}" + return suggestion + + @callback + def async_create( + self, + name: str, + *, + color: str | None = None, + icon: str | None = None, + description: str | None = None, + ) -> LabelEntry: + """Create a new label.""" + if label := self.async_get_label_by_name(name): + raise ValueError( + f"The name {name} ({label.normalized_name}) is already in use" + ) + + normalized_name = _normalize_label_name(name) + + label = LabelEntry( + color=color, + description=description, + icon=icon, + label_id=self._generate_id(name), + name=name, + normalized_name=normalized_name, + ) + label_id = label.label_id + self.labels[label_id] = label + self.async_schedule_save() + self.hass.bus.async_fire( + EVENT_LABEL_REGISTRY_UPDATED, + EventLabelRegistryUpdatedData( + action="create", + label_id=label_id, + ), + ) + return label + + @callback + def async_delete(self, label_id: str) -> None: + """Delete label.""" + del self.labels[label_id] + self.hass.bus.async_fire( + EVENT_LABEL_REGISTRY_UPDATED, + EventLabelRegistryUpdatedData( + action="remove", + label_id=label_id, + ), + ) + self.async_schedule_save() + + @callback + def async_update( + self, + label_id: str, + *, + color: str | None | UndefinedType = UNDEFINED, + description: str | None | UndefinedType = UNDEFINED, + icon: str | None | UndefinedType = UNDEFINED, + name: str | UndefinedType = UNDEFINED, + ) -> LabelEntry: + """Update name of label.""" + old = self.labels[label_id] + changes = { + attr_name: value + for attr_name, value in ( + ("color", color), + ("description", description), + ("icon", icon), + ) + if value is not UNDEFINED and getattr(old, attr_name) != value + } + + if name is not UNDEFINED and name != old.name: + changes["name"] = name + changes["normalized_name"] = _normalize_label_name(name) + + if not changes: + return old + + new = self.labels[label_id] = dataclasses.replace(old, **changes) # type: ignore[arg-type] + + self.async_schedule_save() + self.hass.bus.async_fire( + EVENT_LABEL_REGISTRY_UPDATED, + EventLabelRegistryUpdatedData( + action="update", + label_id=label_id, + ), + ) + + return new + + async def async_load(self) -> None: + """Load the label registry.""" + data = await self._store.async_load() + labels = LabelRegistryItems() + + if data is not None: + for label in data["labels"]: + # Check if the necessary keys are present + if label["label_id"] is None or label["name"] is None: + continue + + normalized_name = _normalize_label_name(label["name"]) + labels[label["label_id"]] = LabelEntry( + color=label["color"], + description=label["description"], + icon=label["icon"], + label_id=label["label_id"], + name=label["name"], + normalized_name=normalized_name, + ) + + self.labels = labels + self._label_data = labels.data + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the label registry.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + """Return data of label registry to store in a file.""" + return { + "labels": [ + { + "color": entry.color, + "description": entry.description, + "icon": entry.icon, + "label_id": entry.label_id, + "name": entry.name, + } + for entry in self.labels.values() + ] + } + + +@callback +def async_get(hass: HomeAssistant) -> LabelRegistry: + """Get label registry.""" + return cast(LabelRegistry, hass.data[DATA_REGISTRY]) + + +async def async_load(hass: HomeAssistant) -> None: + """Load label registry.""" + assert DATA_REGISTRY not in hass.data + hass.data[DATA_REGISTRY] = LabelRegistry(hass) + await hass.data[DATA_REGISTRY].async_load() + + +def _normalize_label_name(label_name: str) -> str: + """Normalize a label name by removing whitespace and case folding.""" + return label_name.casefold().replace(" ", "") diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index b2a93e7302f7e9..12a4cfac406417 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -30,9 +30,7 @@ def __init__( @callback def async_has_timer(self, key: Hashable) -> bool: """Check if a rate limit timer is running.""" - if not self._rate_limit_timers: - return False - return key in self._rate_limit_timers + return bool(self._rate_limit_timers and key in self._rate_limit_timers) @callback def async_triggered(self, key: Hashable, now: datetime | None = None) -> None: @@ -43,7 +41,7 @@ def async_triggered(self, key: Hashable, now: datetime | None = None) -> None: @callback def async_cancel_timer(self, key: Hashable) -> None: """Cancel a rate limit time that will call the action.""" - if not self._rate_limit_timers or not self.async_has_timer(key): + if not self._rate_limit_timers or key not in self._rate_limit_timers: return self._rate_limit_timers.pop(key).cancel() diff --git a/homeassistant/helpers/redact.py b/homeassistant/helpers/redact.py new file mode 100644 index 00000000000000..f8df73b91802fc --- /dev/null +++ b/homeassistant/helpers/redact.py @@ -0,0 +1,75 @@ +"""Helpers to redact sensitive data.""" +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import Any, TypeVar, cast, overload + +from homeassistant.core import callback + +REDACTED = "**REDACTED**" + +_T = TypeVar("_T") +_ValueT = TypeVar("_ValueT") + + +def partial_redact( + x: str | Any, unmasked_prefix: int = 4, unmasked_suffix: int = 4 +) -> str: + """Mask part of a string with *.""" + if not isinstance(x, str): + return REDACTED + + unmasked = unmasked_prefix + unmasked_suffix + if len(x) < unmasked * 2: + return REDACTED + + if not unmasked_prefix and not unmasked_suffix: + return REDACTED + + suffix = x[-unmasked_suffix:] if unmasked_suffix else "" + return f"{x[:unmasked_prefix]}***{suffix}" + + +@overload +def async_redact_data( # type: ignore[overload-overlap] + data: Mapping, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] +) -> dict: + ... + + +@overload +def async_redact_data( + data: _T, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] +) -> _T: + ... + + +@callback +def async_redact_data( + data: _T, to_redact: Iterable[Any] | Mapping[Any, Callable[[_ValueT], _ValueT]] +) -> _T: + """Redact sensitive data in a dict.""" + if not isinstance(data, (Mapping, list)): + return data + + if isinstance(data, list): + return cast(_T, [async_redact_data(val, to_redact) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in to_redact: + if isinstance(to_redact, Mapping): + redacted[key] = to_redact[key](value) + else: + redacted[key] = REDACTED + elif isinstance(value, Mapping): + redacted[key] = async_redact_data(value, to_redact) + elif isinstance(value, list): + redacted[key] = [async_redact_data(item, to_redact) for item in value] + + return cast(_T, redacted) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d1546528ef288a..ee5015ad8625aa 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -274,9 +274,9 @@ async def async_validate_actions_config( hass: HomeAssistant, actions: list[ConfigType] ) -> list[ConfigType]: """Validate a list of actions.""" - return await asyncio.gather( - *(async_validate_action_config(hass, action) for action in actions) - ) + # No gather here because async_validate_action_config is unlikely + # to suspend and the overhead of creating many tasks is not worth it + return [await async_validate_action_config(hass, action) for action in actions] async def async_validate_action_config( @@ -595,7 +595,7 @@ async def _async_delay_step(self): try: async with asyncio.timeout(delay): await self._stop.wait() - except asyncio.TimeoutError: + except TimeoutError: trace_set_result(delay=delay, done=True) async def _async_wait_template_step(self): @@ -643,7 +643,7 @@ def async_script_wait(entity_id, from_s, to_s): try: async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) @@ -1023,7 +1023,7 @@ def log_cb(level, msg, **kwargs): try: async with asyncio.timeout(timeout) as to_context: await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - except asyncio.TimeoutError as ex: + except TimeoutError as ex: self._variables["wait"]["remaining"] = 0.0 if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 30516e3a0991e3..9feabbb45e23c2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -43,6 +43,7 @@ UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass +from homeassistant.util.async_ import create_eager_task from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE @@ -487,33 +488,46 @@ def async_extract_referenced_entity_ids( # Find devices for targeted areas selected.referenced_devices.update(selector.device_ids) - for device_entry in dev_reg.devices.values(): - if device_entry.area_id in selector.area_ids: - selected.referenced_devices.add(device_entry.id) + + if selector.area_ids: + for device_entry in dev_reg.devices.values(): + if device_entry.area_id in selector.area_ids: + selected.referenced_devices.add(device_entry.id) if not selector.area_ids and not selected.referenced_devices: return selected - for ent_entry in ent_reg.entities.values(): + entities = ent_reg.entities + # Add indirectly referenced by area + selected.indirectly_referenced.update( + entry.entity_id + for area_id in selector.area_ids + # The entity's area matches a targeted area + for entry in entities.get_entries_for_area_id(area_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if entry.entity_category is None and entry.hidden_by is None + ) + # Add indirectly referenced by device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in selected.referenced_devices + for entry in entities.get_entries_for_device_id(device_id) # Do not add entities which are hidden or which are config # or diagnostic entities. - if ent_entry.entity_category is not None or ent_entry.hidden_by is not None: - continue - if ( - # The entity's area matches a targeted area - ent_entry.area_id in selector.area_ids - # The entity's device matches a device referenced by an area and the entity - # has no explicitly set area - or ( - not ent_entry.area_id - and ent_entry.device_id in selected.referenced_devices + entry.entity_category is None + and entry.hidden_by is None + and ( + # The entity's device matches a device referenced + # by an area and the entity + # has no explicitly set area + not entry.area_id + # The entity's device matches a targeted device + or device_id in selector.device_ids ) - # The entity's device matches a targeted device - or ent_entry.device_id in selector.device_ids - ): - selected.indirectly_referenced.add(ent_entry.entity_id) - + ) + ) return selected @@ -640,7 +654,7 @@ async def async_get_all_descriptions( descriptions[domain] = {} domain_descriptions = descriptions[domain] - for service_name in services_map: + for service_name, service in services_map.items(): cache_key = (domain, service_name) description = descriptions_cache.get(cache_key) if description is not None: @@ -695,11 +709,10 @@ async def async_get_all_descriptions( if "target" in yaml_description: description["target"] = yaml_description["target"] - if ( - response := hass.services.supports_response(domain, service_name) - ) != SupportsResponse.NONE: + response = service.supports_response + if response is not SupportsResponse.NONE: description["response"] = { - "optional": response == SupportsResponse.OPTIONAL, + "optional": response is SupportsResponse.OPTIONAL, } descriptions_cache[cache_key] = description @@ -926,7 +939,7 @@ async def entity_service_call( # Context expires if the turn on commands took a long time. # Set context again so it's there when we update entity.async_set_context(call.context) - tasks.append(asyncio.create_task(entity.async_update_ha_state(True))) + tasks.append(create_eager_task(entity.async_update_ha_state(True))) if tasks: done, pending = await asyncio.wait(tasks) diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 9bda3ca4eb2ec9..12b78b75fa233d 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -82,7 +82,8 @@ async def _initialize(hass: HomeAssistant) -> None: functions = hass.data[DATA_FUNCTIONS] = {} - async def process_platform( + @callback + def process_platform( hass: HomeAssistant, component_name: str, platform: Any ) -> None: """Process a significant change platform.""" diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index f789aeb37e41fb..44460ffa6017a7 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -9,7 +9,7 @@ from json import JSONDecodeError, JSONEncoder import logging import os -from typing import Any, Generic, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import ( @@ -28,6 +28,12 @@ from . import json as json_helper +if TYPE_CHECKING: + from functools import cached_property +else: + from ..backports.functools import cached_property + + # mypy: allow-untyped-calls, allow-untyped-defs, no-warn-return-any # mypy: no-check-untyped-defs @@ -36,6 +42,7 @@ STORAGE_SEMAPHORE = "storage_semaphore" + _T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) @@ -102,15 +109,16 @@ def __init__( self.hass = hass self._private = private self._data: dict[str, Any] | None = None - self._unsub_delay_listener: CALLBACK_TYPE | None = None + self._delay_handle: asyncio.TimerHandle | None = None self._unsub_final_write_listener: CALLBACK_TYPE | None = None self._write_lock = asyncio.Lock() self._load_task: asyncio.Future[_T | None] | None = None self._encoder = encoder self._atomic_writes = atomic_writes self._read_only = read_only + self._next_write_time = 0.0 - @property + @cached_property def path(self): """Return the config path.""" return self.hass.config.path(STORAGE_DIR, self.key) @@ -125,12 +133,16 @@ async def async_load(self) -> _T | None: Will ensure that when a call comes in while another one is in progress, 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(), f"Storage load {self.key}" - ) + if self._load_task: + return await self._load_task - return await self._load_task + load_task = self.hass.async_create_task( + self._async_load(), f"Storage load {self.key}", eager_start=True + ) + if not load_task.done(): + # Only set the load task if it didn't complete immediately + self._load_task = load_task + return await load_task async def _async_load(self) -> _T | None: """Load the data and ensure the task is removed.""" @@ -273,9 +285,6 @@ def async_delay_save( delay: float = 0, ) -> None: """Save data with an optional delay.""" - # pylint: disable-next=import-outside-toplevel - from .event import async_call_later - self._data = { "version": self.version, "minor_version": self.minor_version, @@ -283,14 +292,38 @@ def async_delay_save( "data_func": data_func, } + next_when = self.hass.loop.time() + delay + if self._delay_handle and self._delay_handle.when() < next_when: + self._next_write_time = next_when + return + self._async_cleanup_delay_listener() self._async_ensure_final_write_listener() if self.hass.state is CoreState.stopping: return - self._unsub_delay_listener = async_call_later( - self.hass, delay, self._async_callback_delayed_write + # We use call_later directly here to avoid a circular import + self._async_reschedule_delayed_write(next_when) + + @callback + def _async_reschedule_delayed_write(self, when: float) -> None: + """Reschedule a delayed write.""" + self._delay_handle = self.hass.loop.call_at( + when, self._async_schedule_callback_delayed_write + ) + + @callback + def _async_schedule_callback_delayed_write(self) -> None: + """Schedule the delayed write in a task.""" + if self.hass.loop.time() < self._next_write_time: + # Timer fired too early because there were multiple + # calls to async_delay_save before the first one + # wrote. Reschedule the timer to the next write time. + self._async_reschedule_delayed_write(self._next_write_time) + return + self.hass.async_create_task( + self._async_callback_delayed_write(), eager_start=True ) @callback @@ -311,11 +344,11 @@ def _async_cleanup_final_write_listener(self) -> None: @callback def _async_cleanup_delay_listener(self) -> None: """Clean up a delay listener.""" - if self._unsub_delay_listener is not None: - self._unsub_delay_listener() - self._unsub_delay_listener = None + if self._delay_handle is not None: + self._delay_handle.cancel() + self._delay_handle = None - async def _async_callback_delayed_write(self, _now): + async def _async_callback_delayed_write(self) -> None: """Handle a delayed write callback.""" # catch the case where a call is scheduled and then we stop Home Assistant if self.hass.state is CoreState.stopping: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 8d837bc9bc6fa8..86e3385a21b86b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -80,6 +80,7 @@ from . import area_registry, device_registry, entity_registry, location as loc_helper from .singleton import singleton +from .translation import async_translate_state from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -665,7 +666,7 @@ def _render_template() -> None: await finish_event.wait() if self._exc_info: raise TemplateError(self._exc_info[1].with_traceback(self._exc_info[2])) - except asyncio.TimeoutError: + except TimeoutError: template_render_thread.raise_exc(TimeoutError) return True finally: @@ -894,6 +895,36 @@ def __repr__(self) -> str: return "