diff --git a/.coveragerc b/.coveragerc index 4be573201d6..378532dfd88 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 16a48d3cb48..333c31ce841 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 f7854ef88df..9a898d11aa5 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 bdec74a3aff..f7d97de0022 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 c9b1a76cc37..bae60e8e945 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 0db0244edc9..4b96b5ee2aa 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 bd92da2fc50..74535719bb3 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 144883db68f..1424469a94b 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 d0baa4ac18e..f6ffac3bd1d 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 cc3d87319d0..4fc9073b146 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -3,9 +3,10 @@ from __future__ import annotations 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 @@ import threading 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 @@ from .helpers import ( 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ DEFAULT_INTEGRATIONS = { # # Integrations providing core functionality: "application_credentials", + "backup", "frontend", "hardware", "logger", @@ -143,15 +163,22 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = { # 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 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> 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), ) - loop_count = 0 - _LOGGER.debug("Running timeout Zones: %s", hass.timeout.zones) + + _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 + ) + 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, + ) + + return domains_to_setup, integration_cache + + +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, {}) + + watcher = _WatchPendingSetups(hass, setup_started) + watcher.async_start() + + 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) - # 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) - - # 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) - - # 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) + 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 b1d113dad73..b3fc7872c85 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 @@ class AccuWeatherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 b0dd287f428..56a11aff200 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 @@ class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } 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 00000000000..fd8686c3741 --- /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 00000000000..9963db68a46 --- /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 1f80553031b..84d9e29a518 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -303,7 +303,7 @@ class AdsEntity(Entity): 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 870a001a10f..6abd0b18fd4 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -17,7 +17,8 @@ from homeassistant.components.climate import ( ) 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_HEAT_TARGET = "myAutoHeatTargetTemp" 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 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): 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 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): 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 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): } ) + 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 5c288b206d0..f019325fb79 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 a58faaf6f6b..bb73311aa55 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -21,7 +21,7 @@ from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN OPTIONS_SCHEMA = vol.Schema( { - vol.Required(CONF_STATION_UPDATES): bool, + vol.Required(CONF_STATION_UPDATES, default=True): bool, } ) OPTIONS_FLOW = { @@ -45,7 +45,7 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 6b11e6aa70f..9623766b64c 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -20,7 +20,7 @@ from aemet_opendata.const import ( 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 @@ FORECAST_MAP = { 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 @@ FORECAST_MAP = { 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 00000000000..f49170d9576 --- /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 2bc30860803..b8a19bcd27a 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 f51bdcf765a..75f7f5c0f97 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -27,7 +27,7 @@ from aemet_opendata.const import ( AOD_TEMP, AOD_TEMP_MAX, AOD_TEMP_MIN, - AOD_TIMESTAMP, + AOD_TIMESTAMP_UTC, AOD_TOWN, AOD_WEATHER, AOD_WIND_DIRECTION, @@ -171,7 +171,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( ), 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 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( 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 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( ), 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 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( ), 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 d0176cde15d..dda5fb7e426 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -22,8 +22,6 @@ CONF_TRACKING_NUMBER: Final = "tracking_number" 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 00000000000..1222ab0873d --- /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 a3b85f2188d..055d31fc16d 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -35,7 +35,6 @@ from .const import ( 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 d7caaa120fc..a494ac0c93f 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 b562e837ff4..4228fea50d7 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -23,6 +23,13 @@ from .const import DOMAIN, MFCT_ID _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 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): 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 1d5babee6d7..42cc1e1fade 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 00000000000..9197830cb63 --- /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 7934d809287..4da5c395765 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 ab80e154903..69835188750 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -42,7 +42,6 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=SENSOR_KIND_LEVEL, name="Air pollution level", - icon="mdi:gauge", device_class=SensorDeviceClass.ENUM, options=[ "good", @@ -63,7 +62,6 @@ GEOGRAPHY_SENSOR_DESCRIPTIONS = ( 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 6987b3213c1..a14215fea6b 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 7e787ef4c69..697b80942f2 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 9f99e49f650..20b747dfae3 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -7,6 +7,7 @@ from typing import Any, Final from aioairzone_cloud.const import ( AZD_ACTIVE, AZD_AIDOOS, + AZD_AQ_ACTIVE, AZD_ERRORS, AZD_PROBLEMS, AZD_SYSTEMS, @@ -76,6 +77,10 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...] 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 32274d4e8ef..0d04f78245d 100644 --- a/homeassistant/components/airzone_cloud/config_flow.py +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -94,6 +94,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 f8b740dc04d..3b8247d003c 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 f45fd248cd5..965ac24a64f 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -5,6 +5,10 @@ from typing import Any, Final 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.components.sensor import ( ) 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 @@ WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( ) 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 12f155b4486..fe7c38c8374 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 3df3c0dbe0a..d1c7bc5668b 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 e5170e9b0a2..d14b7b7c35e 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..80835a049c8 --- /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 f0ffc7e7158..1598171649b 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 527e51b5390..10a7be4967e 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -122,7 +122,7 @@ class Auth: 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 a1ab1d77081..02aaed25742 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -29,12 +29,20 @@ class AbstractConfig(ABC): """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 20e66dfa084..3ad863747e5 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 00000000000..b9716387b53 --- /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 97ecc103661..547b51a0f67 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -27,12 +27,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 240c9780cee..7ed9deec898 100644 --- a/homeassistant/components/ambiclimate/__init__.py +++ b/homeassistant/components/ambiclimate/__init__.py @@ -4,7 +4,7 @@ import voluptuous as vol 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 00000000000..cce21c18c20 --- /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 2b55f7bebb6..15a1a4e1f35 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 1718b559fde..7dd6b455e73 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 00000000000..5f92880b963 --- /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 1c81eacd14a..d2c0cec20eb 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -173,6 +173,7 @@ class Analytics: 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 @@ class Analytics: 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 @@ class Analytics: 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 @@ class Analytics: 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 @@ class Analytics: 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 @@ class Analytics: ) 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 @@ class Analytics: 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 @@ class Analytics: } 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 @@ class Analytics: 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 @@ class Analytics: 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 955c4a813f4..6ab6898ec27 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 d33bb23b1b7..b55e08a8141 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 00000000000..9fa537705e2 --- /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 d7a821d956a..e55112b7259 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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 bae84739079..d2a40cb619a 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 00000000000..e9cbd435d9b --- /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 00000000000..0127d60a72e --- /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 bd058ac769e..5e97396b369 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 @@ from homeassistant.components.media_player import ( 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 @@ from .const import ( 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_DOWNLOAD = "download" 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] + entry_data = hass.data[DOMAIN][entry.entry_id] + aftv: AndroidTVAsync | FireTVAsync = entry_data[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], - ] - + 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 @@ class ADBDevice(MediaPlayerEntity): 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 c78321589a9..9e99a93efa6 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 f45dee34afe..02197a61681 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 0a9edeb2269..a4982b2e9e8 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 00000000000..e31a68464ce --- /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 e4a99a340de..e33c388af8b 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -33,7 +33,6 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( 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 76e88689ca5..d200df743f1 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) _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 99c78fd5d33..25a1ccf7e02 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 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): 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 00000000000..886cf713c5f --- /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 71dc9940b72..4a2261f0b30 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -31,43 +31,42 @@ _LOGGER = logging.getLogger(__name__) 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 @@ SENSORS: dict[str, SensorEntityDescription] = { 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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { 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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 @@ SENSORS: dict[str, SensorEntityDescription] = { ), "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 d012dfc372f..01a84cf606a 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -12,7 +12,6 @@ import voluptuous as vol 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 @@ from homeassistant.const import ( 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 @@ class APIEventStream(HomeAssistantView): 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 @@ class APIStatesView(HomeAssistantView): 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 8f52db13cfa..c369b07de36 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 @@ from homeassistant.const import ( 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 @@ from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN _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 @@ class AppleTVEntity(Entity): ) ) - 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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): "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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): 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 @@ class AppleTVManager(DeviceListener): ) @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 11d408ee2ca..2bb4608dca1 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations 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 import exceptions, pair, scan 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 @@ OPTIONS_FLOW = { } -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 @@ async def device_scan(hass, identifier, loop): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) - 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 1f7ac45372e..0a14e11ecb7 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 789415a1717..a7b5957ecff 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -16,7 +16,15 @@ from pyatv.const import ( 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # "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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): 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 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): 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 24d2ef68ed4..7baa6321f21 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_NAME 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 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): 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 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): 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 00000000000..b5aeea2a55c --- /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 00000000000..96c1e1ac981 --- /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 00000000000..0e38b385450 --- /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 00000000000..baf92294266 --- /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 00000000000..7a67dee46a8 --- /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 00000000000..e2f2bf109ef --- /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 00000000000..43ba4417638 --- /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 00000000000..e996691f21f --- /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 d9ab17dba86..a45dd89e180 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -83,7 +83,7 @@ async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> N 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 7c4ec280101..7ec5bcdfa64 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -325,9 +325,7 @@ class ArcamFmj(MediaPlayerEntity): 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 7f6bef6e3c0..a009cfb1095 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 091b19db69e..ef1ed1177a6 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -10,6 +10,6 @@ DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds 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 209e2611ec0..8b72331817c 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 a98f184094f..bf511f6cff5 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -55,10 +55,11 @@ from .const import ( 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 @@ class PipelineRun: 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 @@ class PipelineInput: 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 bfba8563875..f7a6d3c43fa 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 @@ async def websocket_run( 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 @@ async def websocket_run( # 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 @@ async def websocket_device_capture( ) 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 e4c80a5848d..7fa1e1f14da 100644 --- a/homeassistant/components/asterisk_mbox/__init__.py +++ b/homeassistant/components/asterisk_mbox/__init__.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback 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 00000000000..fb6c0637a64 --- /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 cc06c225d22..cb04ccdec3f 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -211,10 +211,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge): 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 @@ class AsusWrtHttpBridge(AsusWrtBridge): 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 047e9b549d8..1e320bdd72d 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -216,7 +216,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): 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 927eef572f7..d868065be47 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -20,7 +20,7 @@ from homeassistant.helpers import entity_registry as er 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 @@ class AsusWrtRouter: _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 ea27b58d34c..fe16819bf9c 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 @@ class AugustData(AugustSubscriberMixin): 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 @@ class AugustData(AugustSubscriberMixin): 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 fb87a1f7969..9a41d9bad81 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 @@ class ActivityStream(AugustSubscriberMixin): 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 @@ class ActivityStream(AugustSubscriberMixin): _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 @@ class ActivityStream(AugustSubscriberMixin): _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 a1a7adb4ede..ec4eb77605c 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 9b4e118b83e..f2096506c4a 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -43,12 +43,17 @@ class AugustSubscriberMixin: 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 f97647fff0e..dd07e137e5e 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 e2614af6a3e..cf7f38fa32a 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 dbf76a1fe59..98ec92a3771 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -701,7 +701,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): 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 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): 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 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): 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 ff0fe43ea26..72fb0101b24 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 65a425fa5c4..ae5ffcbdb7a 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 d68de7742dc..8e7cda335e6 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -5,6 +5,10 @@ from collections.abc import Callable 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 @@ class AxisBinarySensor(AxisEventEntity, BinarySensorEntity): 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 0b3a93f24fc..a0c71f101ca 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 75354bb9884..cbba23b8b51 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -249,7 +249,10 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): # 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 @@ class AxisOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): # 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 67ef61af8ac..845487b79d7 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.configuration import Configuration 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 @@ class AxisNetworkDevice: 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 @@ class AxisNetworkDevice: 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 @@ class AxisNetworkDevice: 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 20dfedd717b..948a36a78a0 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 10dc8258d7e..cebd2f1206b 100644 --- a/homeassistant/components/axis/light.py +++ b/homeassistant/components/axis/light.py @@ -69,12 +69,12 @@ class AxisLight(AxisEventEntity, LightEntity): 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 @@ class AxisLight(AxisEventEntity, LightEntity): 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 296a3da8b66..5311d18f991 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 adcd1ba5525..c495dfbdc43 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 @@ class AxisSwitch(AxisEventEntity, SwitchEntity): 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 8ce8bee7793..8f19436fb1d 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -14,23 +14,27 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 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 fe0d494a650..4c06f2171b6 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,20 +4,21 @@ from __future__ import annotations 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 @@ class BackupManager: 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 @@ class BackupManager: 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 @@ class BackupManager: 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 @@ class BackupManager: 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 @@ class BackupManager: 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 @@ class BackupManager: 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 fb7e9eff780..be75e4717ef 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 c203019cca9..c1eed4294c2 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,13 +6,18 @@ import voluptuous as vol 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 fcc648f4001..e685ec6dc8c 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 9edb23abcf8..0aaf2189c28 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 eadf18f05da..1d2cd042918 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -17,7 +17,7 @@ from .const import CONF_SYNC_TIME, DEFAULT_SYNC_TIME, DOMAIN _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 ec7a9fe484a..8584ed2783c 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 @@ CIRCULATION_PUMP_DESCRIPTION = 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 @@ class BalboaBinarySensorEntity(BalboaEntity, BinarySensorEntity): 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 00000000000..f6edc45c342 --- /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 00000000000..fb1b6d01ed4 --- /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 00000000000..00b8eb979a2 --- /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 e0af12514da..3c8f82764d4 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 869cabc5a4a..eaafddcabd6 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -523,7 +523,6 @@ class BangOlufsenMediaPlayer(MediaPlayerEntity, BangOlufsenEntity): ) 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 daa23553c96..61dca6550c0 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 c4f13503abf..6446949cb89 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 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): 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 3eaa6d04ed2..566935c405f 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 50c7fad516a..e86d07c8780 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 80a6ceb50e0..f2c01de4f18 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 @@ from .coordinator import BlinkUpdateCoordinator _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 @@ class BlinkSyncModuleHA( 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 @@ class BlinkSyncModuleHA( 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 838020c98c6..ff4fa6380a7 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 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): 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 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """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 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): 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 00000000000..cd8a282737f --- /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 445a469b141..48db78b572c 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 ea31d1b29ab..fb429e79dc8 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -32,7 +32,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 197c8e08685..2b25d1bce0c 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 @@ from .coordinator import BlinkUpdateCoordinator 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 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): 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 @@ class BlinkSwitch(CoordinatorEntity[BlinkUpdateCoordinator], SwitchEntity): 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 00000000000..6f223834e71 --- /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 00000000000..9c8f23a3658 --- /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 604f251bfeb..16b81c3c1e7 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 bluecurrent_api.exceptions import ( ) 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 @@ class Connector: 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 @@ class Connector: 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 @@ class Connector: 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 @@ class Connector: """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 300f2191cdc..c797fec08b0 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 @@ class BlueCurrentEntity(Entity): 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 @@ class ChargepointEntity(BlueCurrentEntity): 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 00000000000..b5a5f2be81e --- /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 326caa70f54..02a40e09089 100644 --- a/homeassistant/components/blue_current/sensor.py +++ b/homeassistant/components/blue_current/sensor.py @@ -124,14 +124,12 @@ SENSORS = ( ), 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 @@ SENSORS = ( 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 @@ SENSORS = ( 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 @@ SENSORS = ( 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 @@ SENSORS = ( 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 293d0cd6ab7..3ba6349b714 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 eba03963ebc..70c19b5fa6f 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -290,7 +290,7 @@ class BluesoundPlayer(MediaPlayerEntity): 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 @@ class BluesoundPlayer(MediaPlayerEntity): 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 @@ class BluesoundPlayer(MediaPlayerEntity): _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 @@ class BluesoundPlayer(MediaPlayerEntity): "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 2dd4f06ecdf..c2f1724b340 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -16,6 +16,7 @@ from bluetooth_adapters import ( 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 174e5c66ce8..cf8590079bc 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -168,7 +168,7 @@ class ActiveBluetoothDataUpdateCoordinator( # 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 3a13dda28a8..d0be6c61811 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -157,7 +157,7 @@ class ActiveBluetoothProcessorCoordinator( # 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 381beb02520..32589d822d3 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -11,7 +11,7 @@ from bluetooth_adapters import BluetoothAdapters 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 @@ class HomeAssistantBluetoothManager(BluetoothManager): 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 @@ class HomeAssistantBluetoothManager(BluetoothManager): 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 a0a61c14e8a..b8158a06f7e 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 453ab996abc..2fd650d9580 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 601f78d4c8d..a92a5317ba4 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 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce 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 3739734223e..f85a9506d72 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_setup_scanner( # noqa: C901 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 d5a213256c3..079563b1ad3 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -10,7 +10,11 @@ import voluptuous as vol 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 29c4d61e9f7..7ff9ad2d8ab 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -130,7 +130,6 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 f2a123fe4a8..74f12c9c721 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -44,24 +44,21 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( 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 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( 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 12d29736183..a97ed1e1092 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 00000000000..a4eb37b369a --- /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 0ed732e1dcb..21326a59118 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -56,7 +56,6 @@ NUMBER_TYPES: list[BMWNumberEntityDescription] = [ 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 8823c6552cc..db426b89487 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -51,7 +51,6 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { 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 @@ SELECT_TYPES: dict[str, BMWSelectEntityDescription] = { 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 d486c41ae56..27a5824a7d7 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -59,7 +59,6 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { "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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { 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 e4ce0ba81ff..7c8952f4ecc 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -56,7 +56,6 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ 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 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ 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 b6f402004f6..2e60512156f 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 26b485127f2..33b5d2bf2c4 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 2c54ad8f3dd..dd307547b81 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 @@ class BondEntity(Entity): """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 00000000000..0b1cb767054 --- /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 df216ed0ff2..c9c194bdc08 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 @@ class EnergySensor(SHCEntity, SensorEntity): 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 03d3ba2f6a9..8e542c860d4 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -199,7 +199,6 @@ class SHCSwitch(SHCEntity, SwitchEntity): 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 59219a34eb7..72d2107271f 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 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): 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 @@ class BraviaTVCoordinator(DataUpdateCoordinator[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 cfa388fcce7..111f08e441a 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 @@ class BraviaTVMediaPlayer(BraviaTVEntity, MediaPlayerEntity): """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 00000000000..2f57bb2e0b3 --- /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 aec3cd53c94..aaf11130b8d 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations 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 122e71feea6..efd99fd938a 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -4,8 +4,8 @@ from __future__ import annotations 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 eb28f24e085..550c589aa4e 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -4,9 +4,9 @@ from __future__ import annotations 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): 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 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[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 00000000000..a757b20a4cc --- /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 e7d23bfc3df..d8bfc6c7ebd 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 14279c894af..5d3fc5bbf68 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 @@ class BringTodoListEntity( 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 @@ class BringTodoListEntity( 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 @@ class BringTodoListEntity( """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 @@ class BringTodoListEntity( 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 dd37d270f9e..be0eaf78f26 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 4e2b64b4f56..4733431f8e2 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 00000000000..0e609f4190a --- /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 06b8574dbb4..26317b39ab5 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 27e4b7fd715..198fe621246 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -16,10 +16,10 @@ from homeassistant.components.sensor import ( 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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ class BrotherPrinterSensor( 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 511701cb538..1be595bf1cc 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -17,6 +17,7 @@ from homeassistant.components.climate import ( 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.helpers.update_coordinator import ( 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 @@ class BSBLANClimate( 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 @@ class BSBLANClimate( 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 689d1f893d3..7a67d353803 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 41440cb435f..62dc8cfa99f 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 @@ import voluptuous as vol 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 2a7cf84f16b..a3e974bf71e 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 1963041bcca..ba62cbfbb19 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -128,7 +128,7 @@ class BuienradarCam(Camera): _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 63e0004dc43..426f982bafc 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 @@ class BrData: 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 fa89d6acc38..3b524e29370 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 073c41fc0df..e4fe5d22efd 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -91,11 +91,24 @@ EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]] 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 @@ class CalendarEventListener: 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 5a78728697b..ff4687dd493 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 async_get_still_stream( 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 e681ddbbd7e..3c9a386f958 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 @@ class CameraMediaSource(MediaSource): 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 + return _media_source_for_camera(camera, camera.content_type) + if not can_stream_hls: + return None - elif can_stream_hls and stream_type == StreamType.HLS: - content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] + content_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER] + if stream_type != StreamType.HLS and not (await camera.stream_source()): + return None - 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, 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 160f896c86c..7f3f142378a 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 @@ class CameraPreferences: 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 @@ class CameraPreferences: 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 @@ class CameraPreferences: # 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 aa0bdfa8118..8c574e0792b 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -8,7 +8,7 @@ from pychromecast import Chromecast 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 @@ class CastProtocol(Protocol): """ -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 730757de8b4..f05c2c4c143 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 @@ SIGNAL_CAST_REMOVED: SignalType[ChromecastInfo] = SignalType("cast_removed") # 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 8b8862ab318..bfe0bc70d79 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 @@ class CastStatusListener( 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 5eec2a28908..f7518b9519a 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 import config_validation as cv, dispatcher, instance_ 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 async_setup_ha_cast( 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 async_setup_ha_cast( 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 00000000000..e19ea0b07b2 --- /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 ae049fefef6..d02bcd3558a 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 b472b18bed0..b2893a54310 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -61,6 +61,7 @@ from .const import ( 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 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): 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 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): 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 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): 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 cde9364214e..6d10d750705 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 00000000000..9d86e701997 --- /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 68e18fddc14..3e171006bdc 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 fcd780dba7d..fc49331c1b7 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 43d98ad6bbd..7e3cb027506 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( 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 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # 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 62952c5aae3..12a8e6f001f 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 ef87f287430..eb9285b0c4f 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 1423330cb44..f1e5d1a6903 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 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement 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 e85c6dd277a..415f2415095 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -246,21 +246,27 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): 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 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): 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 8cf79d20c5d..e569602f944 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -9,7 +9,7 @@ from pathlib import Path 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 @@ class CloudClient(Interface): """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 @@ class CloudClient(Interface): 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 @@ class CloudClient(Interface): """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 @@ class CloudClient(Interface): """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 97d2345f16b..f704fb61f69 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -30,6 +30,8 @@ PREF_GOOGLE_DEFAULT_EXPOSE = "google_default_expose" PREF_ALEXA_SETTINGS_VERSION = "alexa_settings_version" PREF_GOOGLE_SETTINGS_VERSION = "google_settings_version" PREF_TTS_DEFAULT_VOICE = "tts_default_voice" +PREF_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 42f25f43ae1..bda2412b476 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -23,7 +23,6 @@ from homeassistant.components.homeassistant.exposed_entities import ( 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 @@ class CloudGoogleConfig(AbstractConfig): 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 @@ class CloudGoogleConfig(AbstractConfig): """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 @@ class CloudGoogleConfig(AbstractConfig): 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 @@ class CloudGoogleConfig(AbstractConfig): ) ) - @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 @@ class CloudGoogleConfig(AbstractConfig): @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 @@ class CloudGoogleConfig(AbstractConfig): 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 849a1c99db9..4fd9d5c0301 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -44,6 +44,7 @@ from .const import ( 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 @@ _LOGGER = logging.getLogger(__name__) _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 _require_cloud_login( 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 00000000000..06ee7eb2f19 --- /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 d314aac2092..ef2d32fcb0c 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 af5f9213e4d..010a9697f26 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -8,6 +8,9 @@ import uuid 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 @@ from .const import ( 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 @@ from .const import ( 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 @@ from .const import ( 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 @@ class CloudPreferencesStore(Store): 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: (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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 6f1e3c80bf7..4bef2ac9ba3 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 9a62f2d115c..63b57d2fa3d 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 ba34ac7a9b0..59ae5b22214 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -20,8 +20,9 @@ from homeassistant.components.tts import ( 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 @@ SUPPORT_LANGUAGES = list(TTS_VOICES) _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 00000000000..6bf6d773fc3 --- /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 69d2bd9e904..40c8ca0c65a 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 515fe9f9abb..dbb40b24fcc 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 2cc3e206958..e6095c9f925 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -139,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 00000000000..07b449ffc54 --- /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 ef974b8f3ed..195bfa97b7d 100644 --- a/homeassistant/components/comed_hourly_pricing/sensor.py +++ b/homeassistant/components/comed_hourly_pricing/sensor.py @@ -123,7 +123,7 @@ class ComedHourlyPricingSensor(SensorEntity): 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 06db68a2444..2cf7a145eee 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -13,6 +13,7 @@ from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVed 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 4ff75ba5307..fe23cb1f5d3 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -83,8 +83,8 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[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 00000000000..8ec2e9fd28b --- /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 00000000000..6c42d20de65 --- /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 7deb3d49624..a1743bff12d 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -6,7 +6,7 @@ from typing import Any 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 bbbb4efe7d6..d93ec349bba 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 66b04e6ae98..7cdb0535f8c 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -35,7 +35,6 @@ SENSOR_VEDO_TYPES: Final = ( 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 dac8bc4123d..14d947c7323 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 05e5d3b9a2d..fbeb5904a1a 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -26,6 +26,7 @@ from homeassistant.util.yaml.loader import JSON_TYPE _DataT = TypeVar("_DataT", dict[str, dict[str, Any]], list[dict[str, Any]]) DOMAIN = "config" + SECTIONS = ( "area_registry", "auth", @@ -35,6 +36,8 @@ SECTIONS = ( "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 c8dc7450183..31841717109 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -10,7 +10,8 @@ from homeassistant.core import HomeAssistant, callback 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 355dc739a9c..0409bf0f0f4 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -7,7 +7,7 @@ import voluptuous as vol 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 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( ) -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 c8b7e91f5a7..0c58cad536e 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -7,11 +7,12 @@ import voluptuous as vol 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 02131fe2169..cf637b0aa23 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -11,13 +11,14 @@ from homeassistant.components.automation.config import ( ) 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 b19c0101232..52904cb8d35 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -21,16 +21,18 @@ from homeassistant.helpers.data_entry_flow import ( 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 @@ class ConfigManagerEntryIndexView(HomeAssistantView): 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 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): 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 config_entries_subscribe( [ { "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 config_entries_subscribe( ) -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 e6eac5f6e8e..c3e070a3751 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -9,13 +9,14 @@ import voluptuous as vol 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 dfa55b02c30..7bd76310929 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -17,7 +17,8 @@ from homeassistant.helpers.device_registry import ( ) -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 f1c1fadc144..66a1ceeba69 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -18,7 +18,8 @@ from homeassistant.helpers import ( 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 00000000000..4b3ffbd4575 --- /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 00000000000..7ea80231e82 --- /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 efbfd73db05..01bdce0c8bc 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -7,13 +7,14 @@ import uuid 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 aa8a2a52d83..d181ad94286 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -10,13 +10,14 @@ from homeassistant.components.script.config import ( ) 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 06dc62d114b..b93e586b7ca 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 @@ class Control4Validator: ) 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 e4317052b04..6f484941a3d 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 29fd5797124..884d38c77dc 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 e4dfb371a0b..db9dd55ea0b 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 00000000000..f69e60fdee3 --- /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 5c6774e8c92..30b22f4f658 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 9174e8399f3..dc8f722c7ed 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 5eb05afd014..0df83b24d70 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 00000000000..fcc2822920b --- /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 a140de59017..a95238bcdbe 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 e39fe97bc6c..b8e87d2b200 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 b79cc960fce..abd2d78c7fb 100644 --- a/homeassistant/components/daikin/config_flow.py +++ b/homeassistant/components/daikin/config_flow.py @@ -89,7 +89,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..99dfa8efdf5 --- /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 9e7a181ba32..b890ad823f7 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -94,7 +94,6 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( 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 8741898237e..dd157774d6e 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -11,9 +11,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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 @@ class DaikinZoneSwitch(SwitchEntity): 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 @@ class DaikinStreamerSwitch(SwitchEntity): 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 d3ed3564344..fc52557fa5a 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 c0361aa2bca..99fa6412364 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -103,7 +103,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): 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 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): 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 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): } ) - 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 156309c0903..a9286cca112 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -301,7 +301,10 @@ class DeconzGateway: 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 00000000000..5b22daee53f --- /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 27038a07ac3..d618edc93f8 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -63,6 +63,7 @@ FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG} 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 af1824e441c..ef2f4a73c1b 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 63412242dd0..40f4d772670 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 5de61350039..db2598e1f67 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 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): ) 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 9b0d5907b1a..7a3e840ff95 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 @@ class DelugeDataUpdateCoordinator( ) except ( ConnectionRefusedError, - socket.timeout, + TimeoutError, SSLError, FailedToReconnectException, ) as ex: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 73cae4a64b1..644c4cb7860 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 79c18bc0a2e..9c746c633d4 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 555760a5af9..aa5554e9fcc 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 00000000000..ec6bc0854f9 --- /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 0ba8caed6c5..d595c7616ba 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 125fec7caaa..0002b04bd62 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -21,6 +21,7 @@ from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, AvrNetworkError, + AvrProcessingError, AvrTimoutError, DenonAvrError, ) @@ -201,6 +202,16 @@ def async_log_errors( 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 @@ class DenonDevice(MediaPlayerEntity): 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 @@ class DenonDevice(MediaPlayerEntity): """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 @@ class DenonDevice(MediaPlayerEntity): @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 68d05c19f67..2bf87343c72 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 a17972526cf..e1a8058d819 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -714,21 +714,17 @@ class DeviceTracker: 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 35b79b57f1d..cf8358b69a3 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -52,7 +52,6 @@ SENSOR_TYPES: dict[str, 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 9b3dd75ef98..eba1ad05157 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -40,12 +40,11 @@ BUTTON_TYPES: dict[str, 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 @@ BUTTON_TYPES: dict[str, 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 00000000000..816d0e36d03 --- /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 66395e3a465..750bb9ad13d 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -68,14 +68,12 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { 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 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { 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 99c23f77d35..af0569a016f 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 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = { 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 cb75f3bd500..8712eeb10ad 100644 --- a/homeassistant/components/dexcom/const.py +++ b/homeassistant/components/dexcom/const.py @@ -3,7 +3,6 @@ from homeassistant.const import Platform 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 00000000000..9d0b3534e17 --- /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 126d946e57d..592419abc1b 100644 --- a/homeassistant/components/dexcom/sensor.py +++ b/homeassistant/components/dexcom/sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import ( 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 @@ class DexcomSensorEntity(CoordinatorEntity, SensorEntity): 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 b8a12a937e3..ebd0629950e 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -3,18 +3,17 @@ from __future__ import annotations 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 @@ from aiodiscover.discovery import ( 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 homeassistant.loader import DHCPMatcher, async_get_dhcp 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 @@ class WatcherBase(ABC): 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 @@ class WatcherBase(ABC): self.hass = hass self._integration_matchers = integration_matchers self._address_data = address_data + self._unsub: Callable[[], None] | None = None + + @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_stop(self) -> None: - """Stop the watcher.""" - - @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 @@ class WatcherBase(ABC): # 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 @@ class WatcherBase(ABC): 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) + if ( + entry := self.hass.config_entries.async_get_entry(entry_id) + ) and entry.domain in registered_devices_domains: + matched_domains.add(entry.domain) - for matcher in self._integration_matchers: + 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 @@ class NetworkWatcher(WatcherBase): 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 @@ class NetworkWatcher(WatcherBase): 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 @@ class DeviceTrackerWatcher(WatcherBase): 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 @@ class DeviceTrackerRegisteredWatcher(WatcherBase): 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, + @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 ) - 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, - ) - - 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 f190f0ab10e..d609e9ec7ae 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 939bd5f5000..679efd137ce 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -85,7 +85,8 @@ class DiagnosticsProtocol(Protocol): """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 00000000000..9cf0c470847 --- /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 b28c55b022f..14c2cc6c040 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -36,11 +36,9 @@ class DiscordFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 1b53ba83cee..78d4dc203e2 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 43301290490..ff83d97f8c2 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -129,10 +129,10 @@ class DiscordNotificationService(BaseNotificationService): 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 5a3448a9e4b..e5e161a5d40 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -38,7 +38,9 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): 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 f70a531215e..da9fb117353 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 00513db484b..365a67fe552 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -156,7 +156,7 @@ ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( 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 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt 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 749f2c887eb..c8c70486854 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -19,6 +19,7 @@ from homeassistant import config_entries 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 @@ from homeassistant.components.media_player import ( 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 @@ from .const import ( 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 @@ class DlnaDmrEntity(MediaPlayerEntity): 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 @@ class DlnaDmrEntity(MediaPlayerEntity): 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 @@ class DlnaDmrEntity(MediaPlayerEntity): ) ) + 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 @@ class DlnaDmrEntity(MediaPlayerEntity): 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 ( + # 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 - 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 - - 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 @@ class DlnaDmrEntity(MediaPlayerEntity): (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 @@ class DlnaDmrEntity(MediaPlayerEntity): 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 00000000000..ea078158387 --- /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 a4b0d34b339..975ec1992ae 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 1e1b4c55e18..d77ac7a378e 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 a4133f2da2c..3da47eb572a 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -108,7 +108,7 @@ class DoorBirdCamera(DoorBirdEntity, Camera): 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 00000000000..7188080fafe --- /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 00000000000..9e8bf86ff22 --- /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 00000000000..ce48987df58 --- /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 b24b01d2308..98e4cd0e85d 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -51,7 +51,6 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = ( Dremel3DPrinterSensorEntityDescription( key="job_phase", translation_key="job_phase", - icon="mdi:printer-3d", value_fn=lambda api, _: api.get_printing_status(), ), Dremel3DPrinterSensorEntityDescription( @@ -67,7 +66,6 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 @@ SENSOR_TYPES: tuple[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 1bce60f87b3..73e0e254607 100644 --- a/homeassistant/components/drop_connect/binary_sensor.py +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -31,11 +31,6 @@ from .entity import DROPEntity _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 @@ BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ 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 00000000000..9392da79f0c --- /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 e026cfcd59e..ad06576c9f3 100644 --- a/homeassistant/components/drop_connect/select.py +++ b/homeassistant/components/drop_connect/select.py @@ -23,8 +23,6 @@ PROTECT_MODE = "protect_mode" PROTECT_MODE_OPTIONS = ["away", "home", "schedule"] -FLOOD_ICON = "mdi:home-flood" - @dataclass(kw_only=True, frozen=True) class DROPSelectEntityDescription(SelectEntityDescription): @@ -38,7 +36,6 @@ SELECTS: list[DROPSelectEntityDescription] = [ 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 c5215df8395..c9450440473 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -39,9 +39,6 @@ from .entity import DROPEntity _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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 @@ SENSORS: list[DROPSensorEntityDescription] = [ 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 b0ebe4b5a85..98841d7ca24 100644 --- a/homeassistant/components/drop_connect/switch.py +++ b/homeassistant/components/drop_connect/switch.py @@ -25,11 +25,6 @@ from .entity import DROPEntity _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 @@ SWITCHES: list[DROPSwitchEntityDescription] = [ 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 @@ class DROPSwitch(DROPEntity, SwitchEntity): 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 376b4d100fc..a38326c1346 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -123,7 +123,7 @@ class DSMRConnection: 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 00000000000..39a39a47e39 --- /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 79136a27f16..ad1c4e64c55 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -106,7 +106,6 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( 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 4f6bf6fb677..ff7f78d537b 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -5,10 +5,14 @@ from typing import Any, Final 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 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = ( | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA ) @@ -115,7 +121,7 @@ class DuneHDPlayerEntity(MediaPlayerEntity): 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 @@ class DuneHDPlayerEntity(MediaPlayerEntity): """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 d9d890c28f3..288210c7280 100644 --- a/homeassistant/components/duotecno/__init__.py +++ b/homeassistant/components/duotecno/__init__.py @@ -12,11 +12,11 @@ from homeassistant.exceptions import ConfigEntryNotReady 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 00000000000..33c35ecb4cd --- /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 00000000000..3cb01757950 --- /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 00000000000..dedbb1be3ac --- /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 a57558ff1cc..d18553b6ee6 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 2c7f8456a72..297f4d6d2c8 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 homeassistant.helpers.update_coordinator import ( 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 6bc5ed3803a..0c885174872 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 00000000000..90cbec17a65 --- /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 7298c49660f..d719eac17af 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -117,7 +117,6 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 ab83759bb2d..b1eb03989ea 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 00000000000..3e736d0dc68 --- /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 205dfe67deb..7b4dd08610a 100644 --- a/homeassistant/components/ecoforest/__init__.py +++ b/homeassistant/components/ecoforest/__init__.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady 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 00000000000..4cd93399184 --- /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 6f903bee2ba..90904d274ac 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -90,7 +90,6 @@ SENSOR_TYPES: tuple[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 ce7222f96a2..945f999cf79 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -28,6 +28,7 @@ PLATFORMS = [ 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 95e87a04b18..f04f2110003 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 c2e5458c2ed..0e011726010 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ class EcovacsButtonEntity( 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 27b64db20b6..6ba5dcdba6c 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 deebot_client.util.continents import get_continent 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 @@ class EcovacsController: 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 @@ class EcovacsController: 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 d961e231631..6493dce2712 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -3,6 +3,8 @@ from __future__ import annotations 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 20de6914700..817172016bc 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -16,11 +16,12 @@ from homeassistant.helpers.entity import Entity, EntityDescription 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 @@ class EcovacsEntity(Entity, Generic[CapabilityT]): """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 @@ class EcovacsEntity(Entity, Generic[CapabilityT]): 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 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityT]): @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 b639ff81e63..7a57259ca5a 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 18c162138fb..82e20e19732 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 @@ class EcovacsMap( 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 00000000000..e33e87bc5fb --- /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 326c2916bed..52753e6eb39 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 45250ab69b1..0dc379c68f0 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -5,7 +5,7 @@ from collections.abc import Callable 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ ENTITY_DESCRIPTIONS: tuple[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 cd1cdd10379..00e7134266b 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -3,7 +3,7 @@ from collections.abc import Callable 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ ENTITY_DESCRIPTIONS: tuple[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 16a1b4acd43..6efc9ec0385 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Callable 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 homeassistant.helpers.typing import StateType from .const import DOMAIN, SUPPORTED_LIFESPANS from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -62,6 +63,7 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ class EcovacsSensor( class EcovacsLifespanSensor( - EcovacsDescriptionEntity[CapabilityLifeSpan], + EcovacsDescriptionEntity[Capabilities, CapabilityLifeSpan], SensorEntity, ): """Lifespan sensor.""" @@ -227,7 +237,7 @@ class EcovacsLifespanSensor( 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 7a456483877..1f43b830778 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 e9e915877d8..316ed5427ba 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 homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .controller import EcovacsController from .entity import ( + CapabilityDevice, EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, @@ -24,47 +29,92 @@ from .util import get_supported_entitites @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 28750d4f9de..b3e0d4d96be 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -5,6 +5,8 @@ import random 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 a9990bc6fff..0d65d58d84c 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -5,7 +5,7 @@ from collections.abc import Mapping 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 @@ _ATTR_ROOMS = "rooms" 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 @@ class EcovacsVacuum( 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 6d048cc423d..4bcdd2461cd 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -176,6 +176,12 @@ ECOWITT_SENSORS_MAPPING: Final = { 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 00000000000..c26a8a8e50b --- /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 c2fab739789..0126c87b8cd 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -51,25 +51,21 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 ea10cdb4dc4..00ff6749364 100644 --- a/homeassistant/components/electric_kiwi/__init__.py +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -18,7 +18,7 @@ from .coordinator import ( 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 00000000000..1932ce19432 --- /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 4f8cc59757d..83431dfd925 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -52,7 +52,6 @@ ACCOUNT_SENSOR_TYPES: tuple[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 @@ ACCOUNT_SENSOR_TYPES: tuple[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 @@ ACCOUNT_SENSOR_TYPES: tuple[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 @@ ACCOUNT_SENSOR_TYPES: tuple[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 bea60b94a1c..2a929db4b0a 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 @@ class EliqSensor(SensorEntity): _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 113fe2ac84e..03f1f80b4f9 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -296,7 +296,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 @@ async def async_wait_for_elk_to_sync( 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 ac7fc903330..e8d3f8cb0e4 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 069fc3177d6..2be89e7214c 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -207,25 +207,25 @@ class Config: 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 94ac97b6b36..873f446aad8 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -586,21 +586,17 @@ class HueOneLightChangeView(HomeAssistantView): # 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 @@ async def wait_for_state_change_or_timeout( 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 834a9bbb1eb..b684ad5ab8f 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -355,20 +355,19 @@ class EnergyCostSensor(SensorEntity): 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 a4ee4d0d15f..5d9cd81013d 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 3b0c05b7368..b4018a32d3d 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 00000000000..bac061dd318 --- /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 59c44c1aad8..005abb62e91 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -117,7 +117,6 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 5921de15bde..ee1966d5e51 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -136,10 +136,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 61c8a07cfbb..63e10547ead 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 c2ecf8e8a13..c0d1c66deae 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -16,7 +16,14 @@ from pyenphase import ( 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.config_entries import ConfigEntry 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 @@ PRODUCTION_SENSORS = ( 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 @@ CONSUMPTION_SENSORS = ( 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 @@ CONSUMPTION_PHASE_SENSORS = { } +@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 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): 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 f3e78432f90..b0854f64f24 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 00000000000..5e23a96bcfb --- /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 9ec4971f573..143090cc227 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -235,35 +235,30 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = ( 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 ad65bf70275..273dd4f0d0a 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -160,9 +160,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): 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 00000000000..a9237edcfd1 --- /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 021cfd26764..b204ae196e8 100644 --- a/homeassistant/components/escea/climate.py +++ b/homeassistant/components/escea/climate.py @@ -31,7 +31,6 @@ from .const import ( 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 8766c30c04a..eb50e7d0fdc 100644 --- a/homeassistant/components/escea/config_flow.py +++ b/homeassistant/components/escea/config_flow.py @@ -31,7 +31,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: 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 c35e77e2719..363dd166eba 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 00000000000..a9ac95f216c --- /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 e4f44dfd1fd..58f63446da7 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -115,42 +115,42 @@ class EsphomeAlarmControlPanel( 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 24524233a70..37a555f3115 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 a55acf067f0..d59e135d748 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -54,4 +54,4 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): 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 98a4c26621d..0b9c2995dac 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 @@ class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): 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 9c2177800f3..b9952004569 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -286,15 +286,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti 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 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti 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 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti 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 4dee3958515..77c3fee0afc 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -96,31 +96,29 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): 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 14602077a94..7b06fadb33f 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 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): 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 940b1560ba4..a15f68fd6cc 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 @@ class RuntimeEntryData: """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 @@ class RuntimeEntryData: """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 @@ class RuntimeEntryData: ): 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 @@ class RuntimeEntryData: ] 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 4c44134374a..90cda53dee6 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -77,7 +77,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): 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 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): 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 2771e0ccc6b..4f047bad757 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -285,7 +285,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # (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 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): 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 6a0d100e679..55177fd9a51 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -71,13 +71,13 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): 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 59f37d3a078..bd01bea8795 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 @@ from aioesphomeapi import ( UserService, UserServiceArgType, VoiceAssistantAudioSettings, - VoiceAssistantEventType, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -34,14 +32,7 @@ from homeassistant.const import ( 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 @@ class ESPHomeManager: 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 @@ class ESPHomeManager: 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 @@ class ESPHomeManager: ): 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 @@ class ESPHomeManager: ) ) # 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 @@ class ESPHomeManager: 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 @@ class ESPHomeManager: 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 @@ class ESPHomeManager: 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 @@ class ESPHomeManager: 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 @@ ARG_TYPE_METADATA = { } -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 35b8e91f12b..a1841306f0c 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 c77625b14dd..208f1edebeb 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -106,7 +106,7 @@ class EsphomeMediaPlayer( 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 @@ class EsphomeMediaPlayer( 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 f1902bdb39d..2619dbad045 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -79,4 +79,4 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): 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 3d4d296bb87..43965a11df4 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -67,7 +67,7 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self._client.select_command(self._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 efc77ff53b8..d2be19a3fb3 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 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): 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 b2ceaf0fced..a6ecd86f264 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -49,8 +49,8 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): 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 49049eecfd4..337cbb26fee 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -60,4 +60,4 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): 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 ea052522e76..a444c98b987 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 @@ KEY_UPDATE_LOCK = "esphome_update_lock" 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 7c5c74d58ee..15b580a0601 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -160,10 +160,10 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): 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 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): 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 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): 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 00000000000..e8e83232656 --- /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 3278f1c1387..69b88bb01f6 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 beb16115bd7..ab2e116b2a6 100644 --- a/homeassistant/components/evil_genius_labs/config_flow.py +++ b/homeassistant/components/evil_genius_labs/config_flow.py @@ -63,7 +63,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 df6ddc38de7..5cc23b5d73c 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -33,7 +33,6 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( 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 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( 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 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( 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 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( 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 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( 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 00000000000..e5a795a99c4 --- /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 ebc4988e87f..f962d1e7c1a 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 00000000000..5c61065d257 --- /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 2ca0b2d9168..a213898562b 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 42b8a5c0446..cb64acdea14 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -143,10 +143,7 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): 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 00000000000..15829589853 --- /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 c8e5dae5892..7d41989cfca 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -23,13 +23,10 @@ from .coordinator import FileSizeCoordinator _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 @@ SENSOR_TYPES = ( 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 @@ SENSOR_TYPES = ( 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 27f2c4a4526..d2f4e2e11f2 100644 --- a/homeassistant/components/fireservicerota/binary_sensor.py +++ b/homeassistant/components/fireservicerota/binary_sensor.py @@ -49,23 +49,10 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): 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 00000000000..8de4c444ca8 --- /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 1676dc9f2b3..28ce61981c6 100644 --- a/homeassistant/components/fivem/const.py +++ b/homeassistant/components/fivem/const.py @@ -5,10 +5,6 @@ ATTR_RESOURCES_LIST = "resources_list" 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 00000000000..d5d019348d9 --- /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 967a1392fe5..c39f67c5503 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -11,9 +11,6 @@ from .const import ( 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 @@ SENSORS: tuple[FiveMSensorEntityDescription, ...] = ( 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 ba7134d7e50..5732fb3822c 100644 --- a/homeassistant/components/flexit_bacnet/__init__.py +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -11,6 +11,7 @@ from .coordinator import FlexitCoordinator 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 0d8a381a014..84785720fb2 100644 --- a/homeassistant/components/flexit_bacnet/climate.py +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( PRESET_HOME, ClimateEntity, ClimateEntityFeature, + HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry @@ -37,12 +38,12 @@ from .entity import FlexitEntity 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 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity): """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 00000000000..7ce8b116a27 --- /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 00000000000..2731d5e8b09 --- /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 d9efd1fc411..7f763674d00 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 b3751c90f7d..0a7785eaa38 100644 --- a/homeassistant/components/flexit_bacnet/switch.py +++ b/homeassistant/components/flexit_bacnet/switch.py @@ -35,7 +35,6 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = ( 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 557d0492320..842706172f1 100644 --- a/homeassistant/components/flick_electric/config_flow.py +++ b/homeassistant/components/flick_electric/config_flow.py @@ -46,7 +46,7 @@ class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..3164781c1b4 --- /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 b2a0afdcb13..476898c8ef3 100644 --- a/homeassistant/components/flo/sensor.py +++ b/homeassistant/components/flo/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfTemperature, UnitOfVolume, + UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,9 +21,6 @@ from .const import DOMAIN as FLO_DOMAIN 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 @@ class FloSystemModeSensor(FloEntity, SensorEntity): 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 00e5e57498f..62a57c463e2 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -75,13 +75,6 @@ class FloSwitch(FloEntity, SwitchEntity): 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 3fdd54dd40d..c5926e3158e 100644 --- a/homeassistant/components/flock/notify.py +++ b/homeassistant/components/flock/notify.py @@ -58,5 +58,5 @@ class FlockNotificationService(BaseNotificationService): 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 fd6fcc5f4b9..a31fecf305e 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -59,14 +59,12 @@ FLUME_BINARY_NOTIFICATION_SENSORS: tuple[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 00000000000..631c0645ed3 --- /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 100d63d8bf7..2d9dddd3684 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -11,7 +11,7 @@ from flux_led.const import ATTR_ID, WhiteChannelType 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 @@ from .const import ( 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 9094006c791..d50e6a08b5a 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -125,9 +125,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 8b42f5f2e0d..08e1d274ea7 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 @@ DEFAULT_EFFECT_SPEED: Final = 50 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 7aa2d91de4e..8db12cb6e32 100644 --- a/homeassistant/components/flux_led/util.py +++ b/homeassistant/components/flux_led/util.py @@ -12,6 +12,8 @@ from .const import FLUX_COLOR_MODE_TO_HASS, MIN_RGB_BRIGHTNESS 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 a865dd33053..0af1206dbd3 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 @@ class FoobotData: ) 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 c6d4236c219..1d28aad6a92 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 48c2be07c76..df12de944ae 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -668,7 +668,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): 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 @@ class ForkedDaapdMaster(MediaPlayerEntity): 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 057ef4dbe8c..aed3ed637ae 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -66,8 +66,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 00000000000..0c7dba9a4df --- /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 da4e9f53af4..6f256f99854 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 e65856e03f4..feb1fb9fed9 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 00e9f406ed4..f703fadb4b8 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 5b4a3f5a20c..de34056b0d7 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -59,7 +59,6 @@ BUTTONS: Final = [ 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 c9acd60b23c..3d287b57384 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -291,7 +291,7 @@ class FritzBoxTools( 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 @@ class FritzBoxTools( 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 @@ class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrap ) -> 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 00000000000..d2154dc7232 --- /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 53a299cd576..7fcc4944ec5 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -153,13 +153,11 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = ( 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 9bfb1a6a7a0..bcfa945e1df 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 00000000000..a84140617dd --- /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 1ec62c54b6c..c2f635119aa 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 93c13c8e579..2fa4e4fd160 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -157,7 +157,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ LOGGER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 @@ STORAGE_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ 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 09419f2d3bd..48d5bcb0b05 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 21f4df79568..cea376fa8ff 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 4f9dadd6901..00eb1dd7101 100644 --- a/homeassistant/components/fully_kiosk/config_flow.py +++ b/homeassistant/components/fully_kiosk/config_flow.py @@ -58,7 +58,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..760698f7ac8 --- /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 0984d6a220f..8e6d2fad533 100644 --- a/homeassistant/components/fully_kiosk/media_player.py +++ b/homeassistant/components/fully_kiosk/media_player.py @@ -12,7 +12,7 @@ from homeassistant.components.media_player import ( 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 @@ class FullyMediaPlayer(FullyKioskEntity, MediaPlayerEntity): 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 00000000000..156ee85f157 --- /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 3ce96152337..ebda913abbb 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 a79ddc27379..48a3746a762 100644 --- a/homeassistant/components/garages_amsterdam/sensor.py +++ b/homeassistant/components/garages_amsterdam/sensor.py @@ -11,10 +11,10 @@ from . import get_coordinator 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 @@ class GaragesAmsterdamSensor(GaragesAmsterdamEntity, SensorEntity): """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 df41b0a1c43..99c8fa69acf 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 00000000000..00ea9749899 --- /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 00000000000..1a99aa9fb7b --- /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 8039d5274ed..f660c8f73c8 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 00000000000..a03163179cb --- /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 095b46245cf..d69a8a968c7 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -396,9 +396,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): 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 3a964204b70..64fde0dfd26 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( PRESET_ACTIVITY, PRESET_AWAY, PRESET_COMFORT, + PRESET_ECO, PRESET_HOME, PRESET_NONE, PRESET_SLEEP, @@ -86,6 +87,7 @@ CONF_PRESETS = { 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 00000000000..7dce199672b --- /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 dd324492d73..91f7addae44 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -36,14 +36,12 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 ffc34bd2b78..1595b7ad131 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -45,7 +45,7 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..e1d848e276b --- /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 99c1775beef..1b13430128f 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -54,7 +54,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_NO2, subkey="index", value=lambda sensors: sensors.no2.index if sensors.no2 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="no2_index", @@ -106,7 +102,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_O3, subkey="index", value=lambda sensors: sensors.o3.index if sensors.o3 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="o3_index", @@ -123,7 +118,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_PM10, subkey="index", value=lambda sensors: sensors.pm10.index if sensors.pm10 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="pm10_index", @@ -140,7 +134,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_PM25, subkey="index", value=lambda sensors: sensors.pm25.index if sensors.pm25 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="pm25_index", @@ -157,7 +150,6 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( key=ATTR_SO2, subkey="index", value=lambda sensors: sensors.so2.index if sensors.so2 else None, - icon="mdi:molecule", device_class=SensorDeviceClass.ENUM, options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="so2_index", diff --git a/homeassistant/components/glances/icons.json b/homeassistant/components/glances/icons.json new file mode 100644 index 00000000000..6a8c2fa728c --- /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 2119e990e44..f3718dc4c0e 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -47,7 +47,6 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 @@ SENSOR_TYPES = { 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 972106d352f..b0b535ce8ed 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 f32cad5a488..7a7000ba780 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.const import CONF_HOST, Platform 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 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] 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 6d53628f21e..9464067d426 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -22,7 +22,6 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( 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 2d8c0c848c9..2312b6bd183 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -32,7 +32,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """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 00000000000..0bb8b416209 --- /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 0aebdb8c073..f551e09fc6a 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 00000000000..f5abd358baa --- /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 d92f6ab8fd0..09e056da607 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -51,7 +51,6 @@ NUMBERS = ( 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 @@ NUMBERS = ( 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 bc22376e4d9..6d033eab242 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -31,7 +31,6 @@ _OPTION_TO_MODE: dict[str, OperationMode] = { 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 00000000000..6dbad61b43d --- /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 d0705f9382a..01c20595c55 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 94c97357b85..139e3032f14 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -51,7 +51,9 @@ class SyncButton(ButtonEntity): 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 00000000000..6a187113bb9 --- /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 f3d0d24f7c8..28479fd1e97 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -3,7 +3,7 @@ from __future__ import annotations 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 aiohttp.web import json_response 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 import ( ) 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 @@ from .const import ( 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): """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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): 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. + 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. """ - 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 @abstractmethod - def get_agent_user_id(self, context): + 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.""" + + @abstractmethod + 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): @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 @@ class AbstractConfig(ABC): """ 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 @@ class AbstractConfig(ABC): - 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): 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 @@ class AbstractConfig(ABC): "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 @@ class AbstractConfig(ABC): 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 @@ class GoogleEntity: 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 c0e4f715c16..0d75a1bede7 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 import ClientError, ClientResponseError 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 @@ from .const import ( 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 @@ class GoogleConfig(AbstractConfig): 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 @@ class GoogleConfig(AbstractConfig): """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 @@ class GoogleConfig(AbstractConfig): 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 @@ class GoogleConfig(AbstractConfig): _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 @@ class GoogleConfig(AbstractConfig): 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 @@ class GoogleConfig(AbstractConfig): 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 @@ class GoogleAssistantView(HomeAssistantView): 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 00000000000..3bcab03d2c2 --- /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 b8c57812540..8172d0ca92d 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -3,6 +3,7 @@ import asyncio 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 @@ from .const import ( 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 @@ HANDLERS: Registry[ _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 bb03e796d91..169fa30386d 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1942,9 +1942,7 @@ class ModesTrait(_Trait): 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 00000000000..bf1420b2e3f --- /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 720c7d9aa2b..8f30448ad61 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -292,7 +292,7 @@ class GoogleCloudTTSProvider(Provider): ) 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 52dcdb61e8f..1d420cb1497 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 a522eeab5cd..73450e9f5b9 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 00000000000..6544532783a --- /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 00000000000..599ccffe3c7 --- /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 dc1ee33c16e..78b0e3c9a91 100644 --- a/homeassistant/components/google_mail/sensor.py +++ b/homeassistant/components/google_mail/sensor.py @@ -22,7 +22,6 @@ SCAN_INTERVAL = timedelta(minutes=15) 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 00000000000..c8010a690be --- /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 ab20f4cefcd..2d4594755c4 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 8ab14966828..8058668f0ca 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 00000000000..b29640e0001 --- /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 932db081598..135d9c6c28f 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 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -24,6 +27,7 @@ from homeassistant.const import ( 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 @@ ATTR_SPEED = "speed" 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 @@ class GpsdSensor(SensorEntity): @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 @@ class GpsdSensor(SensorEntity): 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 00000000000..ac8e45ebf89 --- /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 07e88223306..e18cf28e174 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 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( 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 894a20629ee..9ee81191bf8 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -383,7 +383,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 5a113491891..c8689cdaa1c 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -30,6 +30,7 @@ from homeassistant.components.light import ( ColorMode, LightEntity, LightEntityFeature, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -162,6 +163,9 @@ class LightGroup(GroupEntity, LightEntity): 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 @@ class LightGroup(GroupEntity, LightEntity): 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 00000000000..4740366e993 --- /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 64c70b07b83..1941dc54248 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -119,7 +119,6 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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 7db0fde8905..ebe8e5549ce 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -80,7 +80,6 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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 @@ VALVE_CONTROLLER_DESCRIPTIONS = ( 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 ffa57322551..a5e91dce813 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 c81ad7860be..41e65ff8b5e 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 @@ from homeassistant.exceptions import ConfigEntryNotReady 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 3d4a87b0407..c94de0db68d 100644 --- a/homeassistant/components/hardkernel/hardware.py +++ b/homeassistant/components/hardkernel/hardware.py @@ -12,6 +12,7 @@ BOARD_NAMES = { "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 1b29f0b0b22..2a528a5173e 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 cc904fbf131..d44a232c232 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 f2772e609db..8056a4cca4f 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 918c96c5643..d4e4f2fed5c 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 44c0fde19c1..f7eb96d6a8f 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 @@ class HarmonyData(HarmonySubscriberMixin): 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 00000000000..f96fd985323 --- /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 e98a15c788f..f08030c0152 100644 --- a/homeassistant/components/harmony/select.py +++ b/homeassistant/components/harmony/select.py @@ -43,13 +43,6 @@ class HarmonyActivitySelect(HarmonyEntity, SelectEntity): 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 1472843e14d..e367a935ace 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -42,6 +42,7 @@ from homeassistant.helpers.storage import Store 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 @@ HARDWARE_INTEGRATIONS = { "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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(_: 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(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: _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 8ebf4bf5cca..db53b2f90fc 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 8d78c878cfa..9b8e6367647 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 homeassistant.loader import bind_hass 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 @@ def _api_bool(funct): 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 @@ class HassIO: 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 @@ class HassIO: 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 9d72d5842fd..d86f1b7dc5c 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 @@ class HassIOView(HomeAssistantView): 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 00000000000..c55820b58f2 --- /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 4f3933d0f5c..f9ff1dd7770 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -18,6 +18,7 @@ from homeassistant.components.http import HomeAssistantView 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 @@ class HassIOIngress(HomeAssistantView): # 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 b49433961e3..0214f28011d 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -50,7 +50,6 @@ STATS_ENTITY_DESCRIPTIONS = ( 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 @@ STATS_ENTITY_DESCRIPTIONS = ( 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 ae04aa0fff5..cf59f8de7f7 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 @@ async def websocket_subscribe( 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 00000000000..3eb027f87a6 --- /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 00000000000..69c434c8287 --- /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 25422004797..f903a9904a9 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 0b10130a88f..46cc37751a0 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 00000000000..671426f6253 --- /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 a340aee0764..d173751c6c8 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -54,6 +54,7 @@ class HiveDeviceLight(HiveEntity, LightEntity): 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 2a92784a54e..0849cf39782 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -8,7 +8,12 @@ from homeassistant.components.sensor import ( 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 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 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 01f695ad1a6..6ea5f9d43db 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 234df998035..5f78d961810 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 00000000000..48965cc554a --- /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 e2a6fc1c9e7..0b20f8698c2 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 036eb07e067..f391b990761 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 fbcd2093778..ed86723ab94 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 @@ from homeassistant.exceptions import ConfigEntryNotReady 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 7c9dd0322ec..d543d562ee3 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 218e0c3e88d..4880d2e375f 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -7,9 +7,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_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, 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 b61e01061c3..84ad464e779 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 dd74df9295f..a9715003172 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 5812bc122c7..a60f55e8bb0 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 @@ class HomeKit: 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 @@ class HomeKit: ) 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 @@ class HomeKit: 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 @@ class HomeKit: # 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 @@ class HomeKit: } ) - 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 @@ class HomeKit: ) 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 470bb78874c..25b1c143f54 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -426,7 +426,9 @@ class HomeAccessory(Accessory): # type: ignore[misc] """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 @@ class HomeAccessory(Accessory): # type: ignore[misc] 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 a6984ae2121..d7c8ea65e2d 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -7,7 +7,7 @@ from operator import itemgetter import random import re import string -from typing import Any +from typing import Any, Final, TypedDict import voluptuous as vol @@ -34,12 +34,6 @@ from homeassistant.helpers import ( 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 @@ MODE_EXCLUDE = "exclude" 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 @@ DEFAULT_DOMAINS = [ "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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 - ): + hk_options = self.hk_options + show_advanced_options = self.show_advanced_options + bridge_mode = hk_options[CONF_HOMEKIT_MODE] == HOMEKIT_MODE_BRIDGE + + if not show_advanced_options or user_input is not None or not bridge_mode: 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] - - 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] + 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 00000000000..fb0461eb5d8 --- /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 ed26265be24..078ab8818ac 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -11,6 +11,7 @@ from pyhap.camera import ( 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 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] 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 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] ) ) - 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 1d60d405502..47660e486f2 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -9,6 +9,7 @@ from pyhap.const import ( 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 @@ class GarageDoorOpener(HomeAccessory): 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 @@ class GarageDoorOpener(HomeAccessory): ) ) - 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 939c1bf37ae..0b2c965c7f3 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -3,6 +3,7 @@ import logging 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 @@ class HumidifierDehumidifier(HomeAccessory): 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 @@ class HumidifierDehumidifier(HomeAccessory): ) ) - 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 4dcc6fb8f65..c638da55764 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 8cd01638679..625ed0a4a44 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -5,6 +5,7 @@ import logging 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 @@ class DeviceTriggerAccessory(HomeAccessory): 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 @@ class DeviceTriggerAccessory(HomeAccessory): 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 ed9b8ca4622..e3ff4d47fcf 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -6,6 +6,11 @@ import contextlib 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 .connection import HKDevice 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 a741cf54920..f1c2440ce9e 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -70,7 +70,6 @@ async def async_setup_entry( class HomeKitAlarmControlPanelEntity(HomeKitEntity, AlarmControlPanelEntity): """Representation of a Homekit Alarm Control Panel.""" - _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index 1c16b2c6483..a0c61578e66 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -5,6 +5,7 @@ characteristics that don't map to a Home Assistant feature. """ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass import logging @@ -32,6 +33,7 @@ _LOGGER = logging.getLogger(__name__) class HomeKitButtonEntityDescription(ButtonEntityDescription): """Describes Homekit button.""" + probe: Callable[[Characteristic], bool] | None = None write_value: int | str | None = None @@ -39,7 +41,7 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { 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 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = { 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 @@ async def async_setup_entry( 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 @@ class HomeKitButton(CharacteristicEntity, ButtonEntity): 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 @@ class HomeKitEcobeeClearHoldButton(CharacteristicEntity, ButtonEntity): 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 @@ class HomeKitProvisionPreferredThreadCredentials(CharacteristicEntity, ButtonEnt 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 c127c6dd95e..0dabc814a7e 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -18,7 +18,7 @@ from aiohomekit.exceptions import ( 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 import device_registry as dr, entity_registry as er 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 @@ from .const import ( 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 @@ class HKDevice: 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 @@ class HKDevice: 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 @@ class HKDevice: 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 @@ class HKDevice: 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 @@ class HKDevice: 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 cc2c28cb5dc..aea5a6661ee 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 @@ HOMEKIT_ACCESSORY_DISPATCH = { ServicesTypes.DOORBELL: "event", ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH: "event", ServicesTypes.SERVICE_LABEL: "event", + ServicesTypes.AIR_PURIFIER: "fan", } CHARACTERISTIC_PLATFORMS = { @@ -105,10 +105,12 @@ CHARACTERISTIC_PLATFORMS = { 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 d87b6ab3e39..1b2d572f2b6 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 00000000000..aeee8dd670a --- /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 1617b907a26..22d78123e0a 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 c453efb8219..e2d856126da 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -28,37 +28,37 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = { 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 e6eae1c51ca..be1a7313301 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -5,7 +5,10 @@ from dataclasses import dataclass 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 @@ SELECT_ENTITIES: dict[str, 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 ebfba110e48..28bb0cd309c 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -3,10 +3,15 @@ from __future__ import annotations 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 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { ], 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 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { 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 @@ class HomeKitBatterySensor(HomeKitSensor): @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 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): ) -> 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 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): @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 998c375aac1..d1205645fd3 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 2ae19152b93..b7e1b27ef7f 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -42,31 +42,31 @@ SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = { 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 @@ class HomeKitSwitch(HomeKitEntity, SwitchEntity): 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 @@ class HomeKitValve(HomeKitEntity, SwitchEntity): """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 33a08504724..489dee5584c 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -11,6 +11,31 @@ from homeassistant.core import Event, HomeAssistant 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 00000000000..2e9f6158c35 --- /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 d75ca02b66f..580a0f637c1 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 65919033801..2b5f2f01cd3 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 2db140d5fe9..149d5b891f4 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 baabf4ca4d8..f58db72a07e 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 6bc6169c68c..fb8537ce36b 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 @@ class HoneywellUSThermostat(ClimateEntity): 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 @@ class HoneywellUSThermostat(ClimateEntity): await self._device.refresh() except ( - asyncio.TimeoutError, + TimeoutError, AscConnectionError, APIRateLimited, AuthError, @@ -525,9 +524,8 @@ class HoneywellUSThermostat(ClimateEntity): 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 43d08ee2294..aeb72899e11 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 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ( aiosomecomfort.ConnectionError, aiosomecomfort.ConnectionTimeout, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" @@ -93,7 +92,7 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 962afef0d90..378a9ac1865 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 6bb0c154540..ab228e32a52 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -32,6 +32,11 @@ from homeassistant.core import Event, HomeAssistant 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 homeassistant.util.json import json_loads 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 99d38bf582e..640d899924e 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -20,13 +20,13 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.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 62569495ba7..0b720b078b9 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -15,7 +15,6 @@ from aiohttp.web import Application, Request, Response, StreamResponse, middlewa 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 df27122b64a..090e5234aeb 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 6e036b9cdc8..b516b63dc5c 100644 --- a/homeassistant/components/http/request_context.py +++ b/homeassistant/components/http/request_context.py @@ -7,10 +7,7 @@ from contextvars import ContextVar 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 1be3d761a3b..ce02879dbb3 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 homeassistant.helpers.http import ( # noqa: F401 + HomeAssistantView, + request_handler_factory, ) -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.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 29c59d3ff9c..81be4e462d1 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 7f709b02dc2..d4fa0b6db6f 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -125,11 +125,6 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): 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 @@ class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): """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 @@ class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): 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 fd1b9850054..1bb5077a2b4 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 00000000000..d105702bf51 --- /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 f211da3c2e8..6fef2d745cb 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 651099be42d..3743716390e 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -107,11 +107,6 @@ class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): 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 @@ class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): 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 c5ceebec3f8..abf91cf4577 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -83,7 +83,7 @@ class HueBridge: 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 7262dea39ef..a1345cf3bba 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -111,7 +111,7 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 4cd6ca143cb..e8d214da3c8 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 f1bcd0bbbe3..4707302d288 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 348d60d8de2..bbf5dc9c19f 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 @@ from homeassistant.components.light import ( 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 @@ async def async_setup_entry( 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 @@ class HueLight(HueBaseEntity, LightEntity): 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 @@ class HueLight(HueBaseEntity, LightEntity): @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 @@ class HueLight(HueBaseEntity, LightEntity): # 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 56f708e2dfd..59dc8de2975 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 b1c2d865e0c..9ea4b547596 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 00000000000..403e757bf2b --- /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 2c1d2ffde68..f07711268d5 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -64,7 +64,6 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ sensor_type=SENSOR_TYPE_RATE, state_class=SensorStateClass.MEASUREMENT, key=SOURCE_TYPE_GAS, - icon="mdi:fire", precision=1, ), HuisbaasjeSensorEntityDescription( @@ -198,7 +184,6 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 @@ SENSORS_INFO = [ 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 56ebbe6fb26..4156dcdafae 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -3,40 +3,23 @@ import asyncio 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 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) 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) - - async with asyncio.timeout(10): - rooms = Rooms(pv_request) - room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) - - async with asyncio.timeout(10): - scenes = Scenes(pv_request) - scene_data = async_map_data_by_id( - (await scenes.get_resources())[SCENE_DATA] - ) - - 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]) - + 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}" + 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: PowerviewData = await rooms.get_rooms() + async with asyncio.timeout(10): + scenes = Scenes(pv_request) + scene_data: PowerviewData = await scenes.get_scenes() + async with asyncio.timeout(10): + shades = Shades(pv_request) + shade_data: PowerviewData = await shades.get_shades() + except HUB_EXCEPTIONS as err: + raise ConfigEntryNotReady( + 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 cb6bc72954f..c37741fcb09 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -5,7 +5,14 @@ from collections.abc import Callable 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.const import EntityCategory 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 @@ from .model import PowerviewDeviceInfo, PowerviewEntryData 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 @@ BUTTONS: Final = [ 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 81532187bbf..97e04b7d522 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -3,14 +3,15 @@ from __future__ import annotations 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 @@ from .const import DOMAIN, HUB_EXCEPTIONS _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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 7dd4c229c48..a2d18c6f512 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 4643536d56d..db4079f2b58 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -5,12 +5,14 @@ import asyncio 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[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 6d050bc1dbd..5b998b697a4 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -4,21 +4,20 @@ from __future__ import annotations 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.core import CALLBACK_TYPE, HomeAssistant, callback 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 @@ PARALLEL_UPDATES = 1 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 - 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() + async def _async_initial_refresh() -> None: + """Force position refresh shortly after adding. - 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, "") + 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 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) - - -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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): @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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): 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 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): # 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 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): | 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 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): 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 @@ class PowerViewShadeWithTiltBase(PowerViewShadeBase): 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 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase): | 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 @@ class PowerViewShadeTDBUBottom(PowerViewShadeDualRailBase): @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 @@ class PowerViewShadeTDBUTop(PowerViewShadeDualRailBase): 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 @@ class PowerViewShadeDualOverlappedBase(PowerViewShadeBase): # 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 @@ class PowerViewShadeDualOverlappedCombined(PowerViewShadeDualOverlappedBase): """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 @@ class PowerViewShadeDualOverlappedFront(PowerViewShadeDualOverlappedBase): 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 @@ class PowerViewShadeDualOverlappedRear(PowerViewShadeDualOverlappedBase): @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 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi | 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 @@ class PowerViewShadeDualOverlappedCombinedTilt(PowerViewShadeDualOverlappedCombi # 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 78f63e16879..424d314c4b9 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 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): 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 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @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 @@ class ShadeEntity(HDEntity): 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 f62879aed78..276b10f5e8d 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 b7ad4a7439c..e2311eb4e4c 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 00000000000..6b18f663c71 --- /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 4676a8d1505..0ba9b13d03b 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.config_entries import ConfigEntry 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 @@ class PowerViewScene(HDEntity, Scene): ) -> 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 65fe61851df..bbe4614afd1 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -3,9 +3,11 @@ from __future__ import annotations 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.const import EntityCategory 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 @@ DROPDOWNS: Final = [ 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 @@ class PowerViewSelect(ShadeEntity, SelectEntity): """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 8e16d53ae09..02b4ae7c557 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -4,7 +4,8 @@ from collections.abc import Callable 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 @@ from homeassistant.components.sensor import ( 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 @@ class PowerViewSensor(ShadeEntity, SensorEntity): 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 fab14b540b7..86f232c3b66 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 aiopvapi.resources.model import PowerviewData +from aiopvapi.resources.shade import BaseShade, ShadePosition -from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE from .util import async_map_data_by_id -POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) - _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 @@ class PowerviewShadeData: """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 @@ class PowerviewShadeData: 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) + 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, 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_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() - 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] - ) + # 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_positions(self, data: dict[int | str, Any]) -> None: + 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 7c17788be83..a107e2c5be4 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 00000000000..a54f98a78c1 --- /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 00000000000..20218229385 --- /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 00000000000..e5dc00ad7cb --- /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 00000000000..f201130ab22 --- /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 00000000000..cafe942a894 --- /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 00000000000..ab30bae45f2 --- /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 00000000000..2840823415a --- /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 00000000000..2edce942f0c --- /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 00000000000..abf27af02f0 --- /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 00000000000..dc40116f31e --- /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 00000000000..970c444737c --- /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 00000000000..d6017de2bd7 --- /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 00000000000..9ba760a90e9 --- /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 00000000000..5c056e57653 --- /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 b30a9b375b0..2267522e21b 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -21,7 +21,6 @@ from .const import ATTRIBUTION, CONF_REAL_TIME, CONF_STATION, DOMAIN, MANUFACTUR 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 @@ _LOGGER = logging.getLogger(__name__) 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 0bfe1dff001..5181de7d2a4 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 1b821025953..ff54c02a2d4 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 df3a873b6c1..3537737f122 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_setup_platform( 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 062548666c4..49eaa2b24a5 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 6f00f63b090..8dbc99c8ada 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 80e07fe1065..84e97534d7c 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 f4e04ea762b..0fb3523a461 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -91,10 +91,14 @@ class IdasenDeskSensor(CoordinatorEntity[IdasenDeskCoordinator], SensorEntity): 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 6eeea6b4a02..c76013f6821 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 0c077f8698e..30c84da40f8 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -39,7 +39,7 @@ class IHCDevice(Entity): 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 4c5a9df8810..164b7048da8 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -15,7 +15,7 @@ import httpx from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.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.config_validation import ( # noqa: F401 ) 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 @@ _RND: Final = SystemRandom() 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 @@ class ImageView(HomeAssistantView): 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 00000000000..39f00b587c0 --- /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 bb356c09367..178d40d1139 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -262,7 +262,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): 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 fea2583a27a..924408c30b9 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 dea7a0e2e71..15b52ce6333 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 49938eaaa0a..f0c9099863a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -30,6 +30,7 @@ from homeassistant.exceptions import ( 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 @@ EVENT_IMAP = "imap_content" 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 @@ class ImapMessage: """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 @@ class ImapMessage: 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): await self.imap_client.stop_wait_server_push() await self.imap_client.close() await self.imap_client.logout() - except (AioImapException, asyncio.TimeoutError): + except (AioImapException, TimeoutError): if log_error: _LOGGER.debug("Error while cleaning up imap connection") finally: @@ -356,6 +362,23 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | 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 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): except ( AioImapException, UpdateFailed, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -450,7 +473,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): except ( UpdateFailed, AioImapException, - asyncio.TimeoutError, + TimeoutError, ) as ex: await self._cleanup() self.async_set_update_error(ex) @@ -466,8 +489,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): 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 00000000000..c7d5151ba49 --- /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 f906270b2f5..367af73810b 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 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterEntity): 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 ad3f282eff7..46cd5ecb6ca 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 00000000000..d14277a46b3 --- /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 e960b5616cb..f307208e537 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -10,9 +10,11 @@ import voluptuous as vol 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 @@ from homeassistant.components.lock import ( 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 @@ class IntentPlatformProtocol(Protocol): 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 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): 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 @@ class OnOffIntentHandler(intent.ServiceIntentHandler): ) 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 @@ class NevermindIntentHandler(intent.IntentHandler): 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 6c6642a0226..59a9d499d93 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 4cb8f921ba4..7668802c9e0 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 f9b93cbe954..866f44f0617 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -217,7 +217,7 @@ class IPMAWeather(WeatherEntity, IPMADevice): 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 cedf0521f95..3625a2d867e 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 0052e90880b..d17a278a106 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -232,15 +232,11 @@ class IndexSensor(IQVIAEntity, SensorEntity): 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 86ef3ce271f..55e5618d9d4 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 2fde06f576d..73696572593 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -39,7 +39,7 @@ from .const import ( 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 00000000000..47aa71b3d9c --- /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 c611bf83050..0c5ea27a0b9 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 3aa81027b4f..8c9815cd425 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 78fd8b2a5b6..d73086f9ab1 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -65,9 +65,7 @@ class Itunes: 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 8e6fe584456..d56fb93d4e6 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -25,7 +25,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: 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 d3291e51bc1..550ca2d9e5d 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers.discovery import async_load_platform 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 bcefe763e15..820f0d1fcc0 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 homeassistant.helpers import issue_registry as ir -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, -) - - -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 35c1853b974..7fdc024df47 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 5dc3e5c3e27..00000000000 --- 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 86e1c92e4da..00000000000 --- 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 b3433948582..00000000000 --- 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 979e540af01..5bdad83ac1e 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 fd2535c5bf3..00000000000 --- 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 5f71e066b9c..00000000000 --- 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 0e3732c66d2..6e25130955b 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 7c373eeeb24..00000000000 --- 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 09d470af1de..6ee73b8ace7 100644 --- a/homeassistant/components/kaiterra/api_data.py +++ b/homeassistant/components/kaiterra/api_data.py @@ -54,7 +54,7 @@ class KaiterraApiData: 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 207c9e353a1..6f33b11742a 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 c51d30431be..c9e81071ad7 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -43,11 +43,10 @@ async def async_setup_entry( 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 05e06d819f1..5abdfe5b4a7 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 8369892be85..228803097d6 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -27,10 +27,12 @@ DOMAIN = "kitchen_sink" 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 00000000000..cdc0cebb348 --- /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 00000000000..fef41f7917c --- /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 4e1e3bd2010..4800104d17d 100644 --- a/homeassistant/components/kitchen_sink/sensor.py +++ b/homeassistant/components/kitchen_sink/sensor.py @@ -11,9 +11,10 @@ from homeassistant.const import UnitOfPower 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( - "statistics_issue_2", - "Statistics issue 2", - 100, - None, - SensorStateClass.MEASUREMENT, - "dogs", # Can't be converted to cats + 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( - "statistics_issue_3", - "Statistics issue 3", - 100, - None, - None, # Wrong state class - UnitOfPower.WATT, + 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( + 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( + 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 dca42ce8361..ecfbe406aab 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 00000000000..e60de2f09c8 --- /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 6e3da8ad523..5338a5fddca 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -57,7 +57,7 @@ from .const import ( 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 @@ class KNXCommonFlow(ABC, FlowHandler): 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 6a304f7de5f..290b560dad5 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 c7bcd90538f..d559cd2005a 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -3,15 +3,12 @@ from __future__ import annotations 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 @@ from .const import ( 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"), -) - -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')" - ), +from .validation import ( + ga_list_validator, + ga_validator, + numeric_type_validator, + sensor_type_validator, + string_type_validator, + sync_state_validator, ) -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 00000000000..c0ac93d19eb --- /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 ba8e762763d..8dd3a823570 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 c3228e1d449..a04415a4f31 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 @@ class Plenticore: "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 00000000000..6d85da71991 --- /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 00000000000..fe60f2fab0e --- /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 0adfc4bebfe..0cdacc8d2e4 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -10,6 +10,7 @@ from .coordinator import LaMarzoccoUpdateCoordinator 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 00000000000..2a08a90a1b2 --- /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 6918741f1d3..4cb9d4a580a 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 @@ class LaMarzoccoEntity(CoordinatorEntity[LaMarzoccoUpdateCoordinator]): 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 70adfe95134..727d3c66009 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 bf866872f5b..05f937f48f6 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -5,7 +5,7 @@ from dataclasses import dataclass 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.config_entries import ConfigEntry 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 @@ ENTITIES: tuple[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 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): """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 7537405c6cd..57421dfee83 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 28317238bf9..101216cd0d4 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 7d03ed2efaf..479e7107025 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # 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 121d2cd913f..d67410c6aa3 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -34,7 +34,7 @@ class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice 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 64a789f3a34..8cb0201033e 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 70b77ba6787..27a273ed7b0 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -79,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 d2cb1749689..fde5c20ebd7 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 test_connect(host, port): 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 54d9be78df9..cfd0ebbd7a7 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -90,7 +90,7 @@ class LGDevice(MediaPlayerEntity): 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 22ac66e3bc9..b6fd67c0356 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 e668a7ad79a..18a8a24cb94 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -315,7 +315,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[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 e04e8afb3df..74ed209742c 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -281,7 +281,7 @@ class LIFXLight(LIFXEntity, LightEntity): """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 @@ class LIFXLight(LIFXEntity, LightEntity): 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 @@ class LIFXLight(LIFXEntity, LightEntity): """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 @@ class LIFXMultiZone(LIFXColor): 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 @@ class LIFXMultiZone(LIFXColor): """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 @@ class LIFXExtendedMultiZone(LIFXMultiZone): 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 feaeba8da8f..5d41839f61d 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 @@ async def async_multi_execute_lifx_with_retries( 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 bcf8ed1dc2c..61656741f82 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 @@ class LifxCloudScene(Scene): 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 4abe18daa21..795975b5c3e 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 @@ class Profile: 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 2a90e3e9e19..213ee37ef37 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -73,7 +73,7 @@ class LocalCalendarEntity(CalendarEntity): 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 f5a24e07b0c..53fd61a2924 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 335a89eab3c..b45eec12e62 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 e94206317d7..292f8237776 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 @@ from .store import LocalTodoListStore _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 @@ ICS_TODO_STATUS_MAP_INV = { } +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 @@ class LocalTodoListEntity(TodoListEntity): 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 @@ class LocalTodoListEntity(TodoListEntity): 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 @@ class LocalTodoListEntity(TodoListEntity): 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 0c614972e1e..891d1fb3fb0 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -145,9 +145,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 839a742224f..b7293087e7e 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 84ae84a3b70..22420f243c6 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 @@ class LazyEventPartialState: 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 29d89a4c22f..af41374ec9b 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -9,7 +9,6 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters from homeassistant.components.recorder.models import ulid_to_bytes_or_none from homeassistant.helpers.json import json_dumps -from homeassistant.util import dt as dt_util 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 82124247adf..0b1b34ca375 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 a14cd60c993..fa358d05fcd 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -22,7 +22,7 @@ from homeassistant.const import ( 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 async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 9785940aca2..be22a9a5d30 100644 --- a/homeassistant/components/logi_circle/config_flow.py +++ b/homeassistant/components/logi_circle/config_flow.py @@ -162,7 +162,7 @@ class LogiCircleFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 188139e6c29..be0f4632c25 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 37156e9ca08..358ccc5ae37 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 cc43baab1c8..e22987ba426 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 aad57897c91..1fae687cbdb 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 33cf6f21d6f..0dceada821e 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 9b243a3ec98..21f7cbd9683 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -117,7 +117,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 af06bf0e0f0..7493878bece 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -14,6 +14,9 @@ LUTRON_CASETA_BUTTON_EVENT = "lutron_caseta_button_event" 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 ffab0689636..eb3e38b2e39 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.core import HomeAssistant 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 - await self._set_brightness(brightness, **kwargs) + 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) + + # 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 is_on(self): + 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) -> 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 e549e37d59d..48445f645aa 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 00000000000..c766e21cf7e --- /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 00000000000..22f5f705870 --- /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 623d0f06295..1becc15624e 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -19,6 +19,7 @@ from homeassistant.exceptions import HomeAssistantError 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(hass: HomeAssistant, config: ConfigType) -> bool: _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 @@ class MailboxMediaView(MailboxView): """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 84acd440044..44f1ad08d39 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 00000000000..76383e3b719 --- /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 3a82e466888..06c205859bb 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 async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 7e6f42f44b4..aa93cef9916 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -10,10 +10,12 @@ from homeassistant.components.light import ( 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 @@ COLOR_MODE_MAP = { 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 @@ class MatterLight(MatterEntity, LightEntity): 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 @@ class MatterLight(MatterEntity, LightEntity): ) ) - 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 @@ class MatterLight(MatterEntity, LightEntity): 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 @@ class MatterLight(MatterEntity, LightEntity): ) ) - 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 @@ class MatterLight(MatterEntity, LightEntity): ) ) - 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 @@ class MatterLight(MatterEntity, LightEntity): 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 @@ class MatterLight(MatterEntity, LightEntity): 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 @@ class MatterLight(MatterEntity, LightEntity): 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 801704c25c5..0e1ed4e80b6 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 f8899ea082f..41aed4be15c 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 @@ class MaxCubeHandle: 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 f3d302fc209..42abed48724 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 @@ class MaxCubeClimate(ClimateEntity): 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 12fdb7f3a06..ee2307fbc84 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 673f0a44374..ffb1d6d4a32 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 00000000000..b0c0e7f559e --- /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 62cf7815613..fdb7fa5f1f2 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 2fa7e87d737..2db3e79dfe9 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 9db44d5276c..88f658a0615 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,7 +60,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 11b044311d2..e4a63e326a6 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 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): 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 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): @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 00000000000..488988ab593 --- /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 00000000000..ec835169231 --- /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 00000000000..89b591c0f41 --- /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 00000000000..cf82a60bfa4 --- /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 00000000000..fb0b5faa020 --- /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 00000000000..cf7644c8dfa --- /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 00000000000..af207507e77 --- /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 00000000000..0efb2ec437b --- /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 00000000000..b4c997c2576 --- /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 00000000000..7616cba41b0 --- /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 00000000000..91b7d66d80f --- /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 00000000000..56db4c00ee3 --- /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 00000000000..6f17a12834e --- /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 00000000000..4a52d95620b --- /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 af0567f99a1..e3f722ae2be 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -334,7 +334,7 @@ class MicrosoftFace: 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 14fbb83b61b..8136334514f 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 44d60d5dcb4..044a45fb9b5 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -import socket import ssl from typing import Any @@ -227,7 +226,7 @@ class MikrotikData: 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 0e2debda33e..2cd6c51546a 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 a2b2de4eda8..d424df620cf 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -149,7 +149,7 @@ class MjpegCamera(Camera): 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 @@ class MjpegCamera(Camera): 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 @@ class MjpegCamera(Camera): 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 @@ class MjpegCamera(Camera): 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 aeab576a7cd..c3d15be3468 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 164f21af15a..e6f7126b0b8 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -197,7 +197,7 @@ class MobileAppNotificationService(BaseNotificationService): 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 1151a5f1f01..e5bb9e8bf38 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -134,7 +134,9 @@ from .const import ( # noqa: F401 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 @@ BASE_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( 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 @@ CLIMATE_SCHEMA = vol.All( 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 d31323a27e9..a57fe53ada7 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -75,6 +75,17 @@ from .modbus import ModbusHub 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 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): 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 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): # 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 194eb56757e..b90f5663643 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 71631352d52..c8e7fc3765e 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -172,9 +172,7 @@ async def async_modbus_setup( 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_modbus_setup( 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 76d8e270ffe..765ce4d8be3 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -8,6 +8,7 @@ from typing import Any import voluptuous as vol +from homeassistant.components.climate import HVACMode from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND_OFF, @@ -29,6 +30,7 @@ from .const import ( 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 +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 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 + 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] - -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) - - 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) - - 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 d2d14f27552..a4bdfd71cce 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 d4d59f83674..22b430731e0 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 69452bf1fec..82afd4d2057 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 45b1e42c8bb..a4868c0a210 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 d93e0091369..588d470bb6c 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 @@ class OptionsFlowHandler(config_entries.OptionsFlow): 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 429259a91c1..4d9f8a7934d 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 e8dc5494f25..f0cb67a6261 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 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """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 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): 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 9dde08af5f0..60d8aae2ff8 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 @@ class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): 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 @@ class MotionTiltDevice(MotionPositionDevice): class MotionTiltOnlyDevice(MotionTiltDevice): - """Representation of a Motion Blind Device.""" + """Representation of a Motionblinds Device.""" _restore_tilt = False @@ -394,13 +399,12 @@ class MotionTiltOnlyDevice(MotionTiltDevice): ) -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 56eccb04eae..36c45c3afc2 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 @@ from .gateway import device_name 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 ac18840ddeb..ff37b640127 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -50,7 +50,7 @@ class ConnectMotionGateway: 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 @@ class ConnectMotionGateway: 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 @@ class ConnectMotionGateway: 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 @@ class ConnectMotionGateway: _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 fa333d9060f..0f9241db7b4 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 dddcb0e00fd..b746b39bdf0 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 7b18979ed0e..ef6fe99b722 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 9b3adb38e0c..0721afa9d3a 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -128,18 +128,16 @@ class MpdDevice(MediaPlayerEntity): 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 593d5bbd202..1412ad63e68 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -7,7 +7,6 @@ from datetime import datetime 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.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, ServiceValidationError, - TemplateError, Unauthorized, ) from homeassistant.helpers import config_validation as cv, event as ev, template @@ -87,11 +85,13 @@ from .const import ( # noqa: F401 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_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 @@ async def websocket_subscribe( ) # 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 164632cdd10..ace3cf9fd64 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -818,25 +818,29 @@ class MQTT: @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 @@ class MQTT: _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 @@ class MQTT: self.hass.async_run_hass_job( subscription.job, ReceiveMessage( - msg.topic, + topic, payload, msg.qos, msg.retain, @@ -917,7 +921,7 @@ class MQTT: 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 94311eeda61..4e85163767c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,7 +44,6 @@ from homeassistant.const import ( 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 @@ from .const import ( CONF_TEMP_STATE_TEMPLATE, CONF_TEMP_STATE_TOPIC, DEFAULT_OPTIMISTIC, - DOMAIN, PAYLOAD_NONE, ) from .debug_info import log_messages @@ -98,13 +96,11 @@ from .util import valid_publish_topic, valid_subscribe_topic _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 @@ DEFAULT_INITIAL_TEMPERATURE = 21.0 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 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset( ) 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 @@ COMMAND_TEMPLATE_KEYS = { 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 @@ PLATFORM_SCHEMA_MODERN = vol.All( 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 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): 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 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): 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 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): 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 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): 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 fba2f13937e..7f97910961d 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 @@ RELOADABLE_PLATFORMS = [ 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 351eb422edc..c245b66fdb1 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -29,6 +29,7 @@ from .const import ( CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, + TEMPLATE_ERRORS, ) from .debug_info import log_messages from .mixins import ( @@ -131,7 +132,10 @@ class MqttEvent(MqttEntity, EventEntity): 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 1f90f0fdb3d..e91a8c5c259 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -24,7 +24,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_ENCODING, CONF_QOS +from .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 @@ class MqttImage(MqttEntity, ImageEntity): @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 3a284c6719c..2fc77fb1d4a 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 4c7837a7a2b..5736f821f69 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -94,6 +94,7 @@ from .const import ( DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, + TEMPLATE_ERRORS, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -110,7 +111,6 @@ from .models import ( MqttValueTemplate, PublishPayloadType, ReceiveMessage, - ReceivePayloadType, ) from .subscription import ( EntitySubscription, @@ -480,7 +480,10 @@ def write_state_on_attr_change( 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 @@ class MqttAttributes(Entity): @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 @@ class MqttAvailability(Entity): 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 @@ class MqttAvailability(Entity): 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 0d009cf356b..1295bfb8ff3 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -15,6 +15,7 @@ import voluptuous as vol 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 @@ if TYPE_CHECKING: 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 @@ class MqttCommandTemplate: 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 @@ class MqttValueTemplate: 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 @@ class MqttValueTemplate: 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 @@ class MqttValueTemplate: 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 ce892e97026..4c37de8204c 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 80a717b1f37..0eda584e95a 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): """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 f478ad712d7..fb47bbfc667 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 e06c0b07c87..21bbcfe69bb 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 0818d68de2b..28cacbe7762 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -109,7 +109,7 @@ async def try_connect( 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 _gw_start( 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 15ae1eb75c2..fcfffc54b31 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 homeassistant.helpers import ( ) 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 5d0fcaf521a..1b74d41bc97 100644 --- a/homeassistant/components/myuplink/api.py +++ b/homeassistant/components/myuplink/api.py @@ -11,7 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow 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 00000000000..b5ade88a002 --- /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 e8377f2682b..c108aa00ebe 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 @@ class OAuth2FlowHandler( 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 9adb1eb0e30..3541a8078c3 100644 --- a/homeassistant/components/myuplink/const.py +++ b/homeassistant/components/myuplink/const.py @@ -5,4 +5,4 @@ DOMAIN = "myuplink" 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 4cd66adab2b..03a902fc4bb 100644 --- a/homeassistant/components/myuplink/coordinator.py +++ b/homeassistant/components/myuplink/coordinator.py @@ -4,8 +4,7 @@ from dataclasses import dataclass 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 00000000000..55cbb07c0d0 --- /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 00000000000..8b16dacfd34 --- /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 303af547335..a76f596ade3 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 00000000000..ddfcdb109d4 --- /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 5b08b26a306..1e4bfed1a20 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 @@ from homeassistant.components.sensor import ( 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,16 +27,120 @@ from homeassistant.helpers.typing import StateType 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, @@ -34,23 +148,40 @@ async def async_setup_entry( 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 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): # 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 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): 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 569e148a5a3..f01bb1990cc 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 00000000000..310c6417133 --- /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 00000000000..2b779e83386 --- /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 28f9c282a73..9df1b93a4d7 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 7eee84a66a4..8f44c28df3a 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -92,7 +92,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..5e55bf145e5 --- /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 5b3c6517f64..cd1543affa2 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -180,13 +180,11 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( 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 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( 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 92feaba13aa..a4f77f59e25 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -100,9 +100,7 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): 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 bfc77a09548..42d4ced6792 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 @@ class NetatmoDataHandler: _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 1ab7a48e1b3..0e33cd9c952 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -197,13 +197,11 @@ class NetdataAlarms(SensorEntity): _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 0644de58ee7..f1954eb50b8 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 de5640beef7..46dc1454a2a 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 0013cd63de1..1384226eac1 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 ca59c7d0e3a..af972fb7509 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 c502f788a86..b0a1d936752 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -43,7 +43,7 @@ class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) 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 00000000000..b62629d3dc9 --- /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 c501142697e..b6864fea50a 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -59,7 +59,6 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 @@ SENSORS: tuple[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 177b4970a93..a01b8a8c3c3 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ SWITCHES = ( 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 @@ class NextDnsSwitch(CoordinatorEntity[NextDnsSettingsUpdateCoordinator], SwitchE 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 88f12ffa4bc..798fcf1ec9d 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 98e075ba3c9..6249979c83d 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 851610ee374..bdc46e75cb8 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 @@ class NightscoutSensor(SensorEntity): """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 726b3fa3db8..bab71df94dc 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 @@ class NmapDeviceScanner: 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 @@ class NmapDeviceScanner: 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 @@ class NmapDeviceScanner: """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 @@ class NmapDeviceScanner: 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 @@ class NmapDeviceScanner: 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 @@ class NmapDeviceScanner: 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 b9464020431..5200f778d4c 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 e91b5cec92d..8ab277c3def 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 406acd6aabd..3ed2c7bdb93 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -4,29 +4,20 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, field from datetime import timedelta -import logging -import traceback from typing import Any from uuid import UUID -from aionotion import async_get_client -from aionotion.bridge.models import Bridge, BridgeAllResponse +from aionotion.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 homeassistant.helpers.update_coordinator import ( ) from .const import ( + CONF_REFRESH_TOKEN, + CONF_USER_UUID, DOMAIN, LOGGER, SENSOR_BATTERY, @@ -53,6 +46,7 @@ from .const import ( 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 8e4d5927152..dfa6dc5ec06 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations 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 1e4adab2910..f43c87b5085 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.config_entries import ConfigEntry 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 @@ REAUTH_SCHEMA = vol.Schema( ) +@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 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - 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 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 0961b7c10c5..b1ea921a71b 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -4,6 +4,9 @@ import logging 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 86b84760016..5c32f235639 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,12 +5,12 @@ from typing import Any 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 @@ TO_REDACT = { 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 f23a082df35..5fc94b5e646 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 a774bfdfad3..059ea551b09 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 1d2c81addfa..f5439895ac9 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 @@ class NotionSensor(NotionEntity, SensorEntity): """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 00000000000..553199b7c7a --- /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 42d95f85937..0ea75590ee3 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 @@ class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enfo 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 @@ class NukiCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enfo 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 @@ class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): 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 e3b2d129017..c01c1c50237 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( 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 @@ class NukiRingactionEntity(NukiEntity[NukiDevice], BinarySensorEntity): 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 00000000000..f74603cb9dc --- /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 b84bee660c1..b2e039ec122 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 e4721d2d41c..e8290f5fa6d 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 a3e16fbad76..0ba0b3dfc5e 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 50ba6c964f3..1a96078c003 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.config_entries import SOURCE_IMPORT, ConfigEntry 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 @@ CONFIG_SCHEMA = vol.Schema( 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 df22cb8d8f8..2d2a9e4a907 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 00000000000..2cb4a6f3c2d --- /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 63d9753ee1d..e9df0ed755c 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 7118944a4ec..599ef5ee22b 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 @@ from aiooncue import ServiceFailedException 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 2840cde704b..e7e30588f8a 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -38,7 +38,8 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[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 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[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 @@ DEVICE_BINARY_SENSORS: dict[str, tuple[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 @@ HOBBYBOARD_EF: dict[str, tuple[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 cc8b14b5d6e..a7d199c21a9 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -236,7 +236,8 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { 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 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireSensorEntityDescription, ...]] = { 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 753f244cfe9..8dbcbdf8978 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_id": { + "name": "Latch {id}" }, - "latch_1": { - "name": "Latch 1" + "leaf_sensor_id": { + "name": "Leaf sensor {id}" }, - "latch_2": { - "name": "Latch 2" - }, - "latch_3": { - "name": "Latch 3" - }, - "latch_4": { - "name": "Latch 4" - }, - "latch_5": { - "name": "Latch 5" - }, - "latch_6": { - "name": "Latch 6" - }, - "latch_7": { - "name": "Latch 7" - }, - "latch_a": { - "name": "Latch A" - }, - "latch_b": { - "name": "Latch B" - }, - "leaf_sensor_0": { - "name": "Leaf sensor 0" - }, - "leaf_sensor_1": { - "name": "Leaf sensor 1" - }, - "leaf_sensor_2": { - "name": "Leaf sensor 2" - }, - "leaf_sensor_3": { - "name": "Leaf sensor 3" - }, - "moisture_sensor_0": { - "name": "Moisture sensor 0" - }, - "moisture_sensor_1": { - "name": "Moisture sensor 1" - }, - "moisture_sensor_2": { - "name": "Moisture sensor 2" - }, - "moisture_sensor_3": { - "name": "Moisture sensor 3" + "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 db9e8f5b0f8..00a3f8f65f4 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -42,7 +42,8 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ DEVICE_SWITCHES: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 @@ HOBBYBOARD_EF: dict[str, tuple[OneWireEntityDescription, ...]] = { 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 013dd2e453f..c6ee74c2c50 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -197,7 +197,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera): 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 603957a230e..c5539818a1c 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -32,7 +32,7 @@ from .parsers import PARSERS # 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 64b46a1da94..0dbebda6962 100644 --- a/homeassistant/components/openalpr_cloud/image_processing.py +++ b/homeassistant/components/openalpr_cloud/image_processing.py @@ -209,7 +209,7 @@ class OpenAlprCloudEntity(ImageProcessingAlprEntity): _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 b78227ed1e5..0425b44d9e6 100644 --- a/homeassistant/components/openexchangerates/config_flow.py +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -81,7 +81,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 c7ee5a7d00c..fb03ab214f3 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 4935af1bc46..25052824ffe 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 catch_request_errors() -> ( 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 @@ class OpenhomeDevice(MediaPlayerEntity): 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 691776e4dfd..6d36bccec65 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 @@ class OpenhomeUpdateEntity(UpdateEntity): 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 cd8b98880d5..12f4724e056 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 07187f3a2ec..70bed0d1665 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -70,7 +70,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 048ffdd237b..a9ea1946f91 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 cfe28e2eacc..22c97d72fa5 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 527856ed56e..78a8315335c 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -32,9 +32,7 @@ class OPNSenseDeviceScanner(DeviceScanner): """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 418f2a5723b..820aac5d20a 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 c7cdaddf382..f743122d0cb 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -38,23 +38,31 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { ), 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 d1d544c2381..f60fd56a9a4 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 3c08a74ed61..fe4cc8c1145 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 b96e276af8b..d0d3f1c1060 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 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): except ( python_otbr_api.OTBRError, aiohttp.ClientError, - asyncio.TimeoutError, + TimeoutError, ): errors["base"] = "cannot_connect" else: @@ -145,13 +144,14 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): 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 9a462c4610b..bd7eb997558 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 ebb928e72d0..472313aa315 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 a982325fceb..65670dd7f92 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 b6d31a8e685..2c24ca4f832 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 homeassistant.helpers.entity_platform import AddEntitiesCallback 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 c74ff2829cc..72230c99a05 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_electrical_towel_dryer import AtlanticElectricalTowelDryer 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 @@ WIDGET_TO_CLIMATE_ENTITY = { 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 25dab7c1d7e..157ec72a249 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 @@ OVERKIZ_TO_PRESET_MODES: dict[str, str] = { 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 fe9f20b05fc..cfb92067875 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.components.climate import ( ) from homeassistant.const import UnitOfTemperature +from ..coordinator import OverkizDataUpdateCoordinator from ..entity import OverkizEntity OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = { @@ -25,16 +26,48 @@ HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} 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 @@ class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): 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 00000000000..a30cb93f287 --- /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 00000000000..bf6bb5f95d5 --- /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 e5c1665b2e4..db24a299f2a 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 c15a7bd3acc..b53dbb5db75 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ 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 38b6a351f29..80ab929231e 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -159,12 +159,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """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 bcdc4195100..52e988f0f60 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -29,7 +29,7 @@ from .const import ( 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 f38d320b454..d193fd7487a 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 2f3c4c04c50..0213fb6a4b6 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ) 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 8a504248f5a..8af6fcf5ab1 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: tuple[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 3a7db248862..2075c3fc713 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -482,18 +482,24 @@ class Person(collection.CollectionEntity, RestoreEntity): 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 00a9f534852..61af7e5cc91 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -42,7 +42,7 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): 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 60568e722ef..f4d2e539b6a 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -5,7 +5,6 @@ from collections.abc import Callable 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 setup(hass: HomeAssistant, config: ConfigType) -> bool: {"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 ce3d5c3b461..e3ebaffec12 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -141,7 +141,7 @@ class PingDataSubProcess(PingData): 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 4bbf1225a92..1a7ff877bb8 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 @@ class PjLinkDevice(MediaPlayerEntity): 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 8fc01140787..99dd44c1ed8 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 4af2c0b4c75..2a57dd4350f 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 201e397ba7d..718e4a831c9 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -95,7 +95,7 @@ class PointFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 d975537ca61..086e49eef94 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 @@ class PowerwallDataManager: 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 e86949e2227..8b347ef49c1 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -125,18 +125,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 4185e90ab7b..df57396c7bf 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 5e4408bba20..89240820057 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -45,7 +45,6 @@ _KNOWN_LRU_CLASSES = ( "StatesMetaManager", "StateAttributesManager", "StatisticsMetaManager", - "IntegrationMatcher", ) SERVICES = ( diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py index d0b35aaf4b9..c365ce151ec 100644 --- a/homeassistant/components/prowl/notify.py +++ b/homeassistant/components/prowl/notify.py @@ -73,5 +73,5 @@ class ProwlNotificationService(BaseNotificationService): 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 f3306bebf39..1a549d22f81 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOM 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 94cf21e13df..08670ef5433 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 378c5e7395a..e4e9b9d719c 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 1c87a275126..f68ad6ce896 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 + 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) + # Remove old entity entry. + registry.async_remove(entity_id) - # Format old unique_id. - unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_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 + # 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 f14ef6ce2aa..42a1021afe4 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 @@ class PS4Device(MediaPlayerEntity): except PSDataIncomplete: title = None - except asyncio.TimeoutError: + except TimeoutError: title = None _LOGGER.error("PS Store Search Timed out") @@ -345,11 +344,13 @@ class PS4Device(MediaPlayerEntity): _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 a4fec1c3d4d..2cedcb8598a 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 7b49a6b1b0d..c10c6de5b3f 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 discover_scripts(hass): 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 execute(hass, filename, source, data=None, return_response=False): "_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 5b7837a9694..a90085afb4f 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 @@ class QingpingConfigFlow(ConfigFlow, domain=DOMAIN): 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 5cde039c5ce..c25652ca91e 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 5e7d9948309..94ccbbd4c18 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 70cd07f4d91..e265740179d 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 3aa94e0d402..e8e03fd1828 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 808ee56b092..86a9fe58013 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 ca488ade461..c370cc86484 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 ffc6bfcc8ba..5b0161d9f22 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -from socket import timeout from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -39,7 +38,7 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[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 f7eab3bc2f2..2a660435e17 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -11,11 +11,10 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform 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 f90e13d37f3..a4fceceede9 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -20,7 +20,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD 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 @@ from .const import ( DOMAIN, TIMEOUT_SECONDS, ) +from .coordinator import async_create_clientsession _LOGGER = logging.getLogger(__name__) @@ -101,9 +101,10 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): Raises a ConfigFlowError on failure. """ + clientsession = async_create_clientsession() controller = AsyncRainbirdController( AsyncRainbirdClient( - async_get_clientsession(self.hass), + clientsession, host, password, ) @@ -114,7 +115,7 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 9f1ea95b333..70365c2f095 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -9,6 +9,7 @@ from functools import cached_property import logging from typing import TypeVar +import aiohttp from pyrainbird.async_client import ( AsyncRainbirdController, RainbirdApiException, @@ -18,6 +19,7 @@ from pyrainbird.data import ModelAndVersion, Schedule 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 @@ UPDATE_INTERVAL = datetime.timedelta(minutes=1) # 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 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): _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 b8cb86264f2..7823626f54c 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 da3979a27fd..810a6fbb721 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -103,6 +103,10 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) 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 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) ) 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 cd8ce68c7e7..2f0234efb7a 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -106,7 +106,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 900c947821d..3e463af9ba4 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 5c3ff18f71c..2e821fc7a7a 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_setup_entry( # noqa: C901 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_setup_entry( # noqa: C901 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 dfb03b11b5d..a557b701824 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 @@ class RainMachineDataUpdateCoordinator(DataUpdateCoordinator[dict]): # pylint: 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 164f184ae88..0faad1d8093 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 3750a1c7068..19697d9b69d 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 @@ from homeassistant.exceptions import ConfigEntryNotReady 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 d30c637d2c3..5ed68154ce1 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 00000000000..b8bd4e9f0a2 --- /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 076067312eb..5f3a50e93ed 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 5989fb1cfe3..da5394a9341 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -92,3 +92,5 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): 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 c82d431a8fa..2217d6c7d4e 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( 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 07591c468b8..8885116dbfd 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -187,6 +187,7 @@ class Recorder(threading.Thread): 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 @@ class Recorder(threading.Thread): 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 @@ class Recorder(threading.Thread): 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 @@ class Recorder(threading.Thread): 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 13ba7400952..98b6d15facb 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 0b63bb8daa2..a9d8c0b2482 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -10,8 +10,6 @@ from typing import TYPE_CHECKING 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 39821cb9699..f2b4df1d0cc 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 @@ from .statistics import ( 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 @@ UNIT_SCHEMA = vol.Schema( 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 aee5dec0599..c046f93cdc0 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -19,6 +19,7 @@ PLATFORMS = [ 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 117fadb502b..5cdf0e4787b 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 a60adccade5..e6bd2717981 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -5,7 +5,12 @@ import logging 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 @@ CMD_MAPPING = { 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 @@ class RensonFan(RensonEntity, FanEntity): 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 @@ class RensonFan(RensonEntity, FanEntity): """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 801c25e6ab2..367b4a47a63 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -172,14 +172,12 @@ SENSORS: tuple[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 da385ef07bd..b756d16ea79 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 00000000000..a724dcc5530 --- /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 bb8c9427a9c..3196dbf3ad7 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -84,6 +84,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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 0f2ef19ba87..81d11e2fd0a 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 92e9a6164f8..fb4d42bb97d 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 54b1f249ca6..f2ce3bac84e 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 7dbe295afee..e021b72ff3d 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 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): _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 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): _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 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): 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 c99df16170b..199186cf222 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_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) 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 42b6d9a3ecf..5b90e656911 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -285,7 +285,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 0d0cf218cd0..7917fa0bded 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 ffbc3d26421..0f3988442c7 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 @@ from homeassistant.const import ( 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 @@ from .const import ( 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 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> 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 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> 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 12b9290af99..8f6ff45840c 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -372,7 +372,7 @@ class OptionsFlow(config_entries.OptionsFlow): 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 @@ class OptionsFlow(config_entries.OptionsFlow): ) # 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 + conn = rfxtrxmod.PySerialTransport(device) - if conn.serial is None: - return False - - 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 1e2a3d6da27..ec902855f27 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 1b0a83f1c05..53575e79c45 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 5b6412caffa..943b1c628bf 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -77,7 +77,7 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): 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 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]): 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 356eb1c2b9b..32382a2f929 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -162,6 +163,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( 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 6b4ad1a5c4a..ebe8f34c892 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -267,10 +267,11 @@ class RMVDepartureData: 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 a5c896f3740..f4293213c00 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 d0ed508df4c..7154a36f7b8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -59,6 +59,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): 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 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): 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 b2a14b57819..66957232679 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -66,13 +66,7 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): 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 + + 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, + ) ) - 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] - ) + 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 ddb65c3187c..a7a7fe01d23 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 586e2a5f062..bd302e16a90 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 e2876e9f3b4..530ba8e8137 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 afbf0e6b4a7..5fce298a56b 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -268,9 +268,10 @@ class RoonDevice(MediaPlayerEntity): 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 b67acb1e512..03c6ddbb12c 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 df5027ebaa8..89cc22ef766 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -18,6 +18,7 @@ from .const import ( 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 f07929c0ab4..592c82adc68 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -33,7 +33,12 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]): 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 35e4b155b28..a6b5b8df93d 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 .const import DOMAIN 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 2909d6c1b9b..c58bf5b93ba 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 00000000000..a7109c35339 --- /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 00000000000..61078e6c432 --- /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 2ced868ada7..56fd230fd6f 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -93,9 +93,10 @@ class DebouncedEntryReloader: 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 2e6f64f08e1..e7f71210dfe 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 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 00b8fec8e6a..63a78925a6e 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 14589274da6..44fce7f953f 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -219,7 +219,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): 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 72d5ad54565..23b36ddae0b 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 ade210b304a..f39f662de3e 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 00000000000..c59e133fc24 --- /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 cfe1a12a24f..3ad35ff345d 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 @@ FROM_GRID_ID = "from_grid" 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 5440372cbc8..1adfe3ecbd3 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -300,7 +300,9 @@ class SenseTrendsSensor(CoordinatorEntity, SensorEntity): @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 923bc3eae1f..9a278d0c4df 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 b7d4bca890e..d826e854fa0 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -57,15 +57,13 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 d6dbe957def..0b5f151c49f 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 @@ TIMEOUT = 8 SENSIBO_ERRORS = ( ClientConnectionError, - asyncio.TimeoutError, + TimeoutError, AuthenticationError, SensiboError, ) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 05fec64608f..9f525c3d498 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -15,8 +15,6 @@ from typing import TYPE_CHECKING, Any, Final, Self, cast, final 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 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): 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 aad882821d6..3dc8f878791 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -640,7 +640,11 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { 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 9a0ecbeb9a5..a53ae906718 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 3c3eaeb78e3..425225e07ef 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 9cc3c8ffd57..dd61e1627b4 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant 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 @@ class SeventeenTrackData: 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 @@ class SeventeenTrackData: _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 @@ class SeventeenTrackData: # 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 f80e7acf9a6..53a8c4cba3d 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 1957d12048f..c0ca5e1b9e5 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 e1330b06c08..c378797f56e 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -60,10 +60,10 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator[bool]): # pylint: disable= 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 67258d701e9..5aa8dadee19 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -90,7 +90,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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 142b5f9c521..4895e2a1a2b 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -208,7 +208,7 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b 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 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo 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 17f60f566aa..f4294dee9ee 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -3,6 +3,7 @@ from __future__ import annotations 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 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ 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 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ 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 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ 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 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ @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 + coordinator = entry_data.block - if coordinator is not None: - await er.async_migrate_entries( - hass, config_entry.entry_id, _async_migrate_unique_ids - ) + if TYPE_CHECKING: + assert coordinator is not None - entities: list[ShellyButton] = [] + await er.async_migrate_entries( + hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator) + ) - for button in BUTTONS: - if not button.supported(coordinator): - continue - entities.append(ShellyButton(coordinator, button)) - - 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 59343ca6d2f..3ceb38c84c3 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -43,7 +43,12 @@ from .const import ( ) 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 @@ class BlockSleepingClimate( 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 86fd98b527e..4afe66199f0 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -279,7 +279,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): 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 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): 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 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) -> 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 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): 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 3dd156e9e30..513e2c88998 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 00000000000..1baf61acf3b --- /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 e08b04d16a3..0e0f9d7d796 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 4cab817e67c..ef3963c53c3 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 e46800963a3..b88b6886b84 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -51,7 +51,11 @@ from .entity import ( 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 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { 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 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { 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 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { 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 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = { key="valve|valve", name="Valve status", translation_key="valve_status", - icon="mdi:valve", device_class=SensorDeviceClass.ENUM, options=[ "checking", @@ -907,9 +909,7 @@ RPC_SENSORS: Final = { 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 @@ RPC_SENSORS: Final = { 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 e5d91943a55..a45fd9295f2 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,7 +5,13 @@ from dataclasses import dataclass 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.core import HomeAssistant, callback 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 @@ from .utils import ( 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 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): 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 f5196504fe6..9389f4e1507 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 9cdca39592a..058b01535ea 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 00000000000..af9ef7d4d48 --- /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 d0ddb30c5ee..b1734de4598 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 00000000000..01290b93fc8 --- /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 d1a3d5ae95f..47b74c53db6 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 53428131e17..dc0e4e93eff 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 72157e086e3..353e2093997 100644 --- a/homeassistant/components/smarttub/controller.py +++ b/homeassistant/components/smarttub/controller.py @@ -56,7 +56,7 @@ class SmartTubController: # 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 5b3f60f4b08..1dbfb5ecedd 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 05683f19b11..5814db8168e 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -171,7 +171,7 @@ class SmhiWeather(WeatherEntity): 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 6a787dd5e88..bac51150eba 100644 --- a/homeassistant/components/snapcast/server.py +++ b/homeassistant/components/snapcast/server.py @@ -134,6 +134,7 @@ class HomeAssistantSnapcast: ---------- 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 2756b97157c..dd9a2f5270a 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 d0fe393d550..a30cf93bcde 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -273,13 +273,13 @@ class SnmpSwitch(SwitchEntity): ) 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 d2188eeec73..7174fbc358c 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -144,7 +144,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): 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 8ac6c4672fd..7883c88f0b8 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 de38ac271ce..e42191c1230 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 c592e8435c2..69d2ba76e22 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 7a8ced30eb7..582e62a67eb 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 c79856c58b6..0df6a7422fe 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -393,7 +393,7 @@ class SonosDiscoveryManager: OSError, SoCoException, Timeout, - asyncio.TimeoutError, + TimeoutError, ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( @@ -447,7 +447,7 @@ class SonosDiscoveryManager: 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 58a0ec3b7ee..929b6639e9f 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 fea5b5de7de..3c9e4692fdc 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -613,7 +613,9 @@ class SonosSpeaker: 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 @@ class SonosSpeaker: 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 1a2a868608e..32b63c42370 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 async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _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 84f2bc102e3..94475794fdf 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 1188a9ec05e..b440b795e0e 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 c4e6db4c623..063627f9f43 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 b155c7eddc0..d2786bf213b 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -140,7 +140,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 a2df2c313cd..69647925c47 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -51,6 +51,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url 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 @@ class Scanner: 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 @@ class Scanner: ) ) 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 @@ class Scanner: self.hass.async_create_task( self._ssdp_listener_process_callback_with_lookup( ssdp_device, dst, source - ) + ), + eager_start=True, ) return @@ -501,7 +509,8 @@ class Scanner: 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 2737565822d..6d8010d6b8e 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 ca8118d6b43..1ddcbc9373b 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -44,7 +44,7 @@ class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): @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 90cb80a9642..817780a9282 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -186,6 +186,7 @@ STATS_BINARY_PERCENTAGE = { 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 @@ _PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend( 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 @@ PLATFORM_SCHEMA = vol.All( _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 @@ class StatisticsSensor(SensorEntity): 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 @@ class StatisticsSensor(SensorEntity): 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 @@ class StatisticsSensor(SensorEntity): 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 @@ class StatisticsSensor(SensorEntity): 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 @@ class StatisticsSensor(SensorEntity): 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 @@ class StatisticsSensor(SensorEntity): 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 ee46d644847..e84615b9352 100644 --- a/homeassistant/components/steamist/__init__.py +++ b/homeassistant/components/steamist/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) 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 f182189a9c7..3d5fe000f35 100644 --- a/homeassistant/components/steamist/config_flow.py +++ b/homeassistant/components/steamist/config_flow.py @@ -170,6 +170,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 cacd79b77ac..ae75193a3cc 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 5768f886adb..1d2957b35a3 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -333,7 +333,7 @@ class StreamOutput: 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 3d27637c989..0badd8ebc42 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -421,8 +421,7 @@ class PeekIterator(Iterator): # 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 66c3981705c..02d78dfee41 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 ba288c90e34..d01b8035a0c 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 d87b711e376..d0713ddf1d1 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 00520914b9f..fbc1af5a126 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 3fe2ff7bc7d..d94c7c9f098 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 dee1fe5cd8f..d5e182a31dc 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -156,7 +156,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> 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 1965867887c..29679605e8b 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -115,7 +115,7 @@ class SwitchbotDataUpdateCoordinator(ActiveBluetoothDataUpdateCoordinator[None]) 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 2085398232f..64571f15af0 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 @@ class SwitcherThermostatButtonEntity( 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 01c4814f985..180b71b1fe6 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 @@ class SwitcherClimateEntity( 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 1e34ddd2325..4d81480e136 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 @@ class SwitcherCoverEntity( 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 88867393834..c24157f70fc 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 @@ class SwitcherBaseSwitchEntity( 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 8060bce5c9b..2820c99f889 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 9eec64ec5f6..d2f5c795b7f 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 1d36c673eb6..7c2607e3506 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -23,10 +23,6 @@ from .entity import SystemBridgeEntity 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 a001f22c9e8..0b6a8b4622b 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 5a606721b00..532092ab133 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -215,7 +215,7 @@ class SystemBridgeDataUpdateCoordinator( ) 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 cd3cad8024e..7c4d0f9ac46 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 69dbb1f7952..9fc5c91f085 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 00000000000..89c2e9d854e --- /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 6d9787a39f5..b9b95a4a094 100644 --- a/homeassistant/components/systemmonitor/config_flow.py +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -6,8 +6,8 @@ from typing import Any 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 798cb82f8ef..1f254ca92d6 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 bf625eacf9a..6f93b9ddce8 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -8,12 +8,16 @@ import logging 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 @@ dataT = TypeVar( | dict[str, list[snicaddr]] | dict[str, snetio] | float - | list[psutil.Process] + | list[Process] | sswap | VirtualMemory | tuple[float, float, float] @@ -49,10 +53,12 @@ dataT = TypeVar( ) -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 @@ class MonitorCoordinator(DataUpdateCoordinator[dataT]): 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 @@ class MonitorCoordinator(DataUpdateCoordinator[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 00000000000..d48097e936c --- /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 b93bdefd838..5e1ef6c02de 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 00000000000..10b5d18830d --- /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 813104e2de3..1ebf2ba44e4 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 sys 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 @@ from homeassistant.const import ( 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 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = { 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 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 @@ async def async_setup_entry( # noqa: C901 ) ) + 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 ff1fbc221ee..aae2463c9da 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 11d8fa9c062..c67d4771ff4 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 871d6c2e6b1..c7225caaff9 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,10 +10,11 @@ from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME 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 @@ from .const import ( UPDATE_MOBILE_DEVICE_TRACK, UPDATE_TRACK, ) +from .services import setup_services _LOGGER = logging.getLogger(__name__) @@ -52,6 +54,14 @@ SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) 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 @@ class TadoConnector: 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 0f7a1b2b307..c033ef62e03 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): 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 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): 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 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): 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 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): 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 dd0d6a22a08..5d17655c104 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 import config_validation as cv, entity_platform 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 @@ from .const import ( 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) @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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): ) @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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): # 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): 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 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): # 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 ee24af29b9d..6f32eb1a05c 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -204,3 +204,11 @@ TADO_TO_HA_OFFSET_MAP = { # 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 00000000000..a5c5387ce94 --- /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 0f66798f864..a5cfb919a41 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 d50d1490566..267cbbe6fee 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 b7e68bbb100..cdbc041f535 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 import config_validation as cv, entity_platform 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 @@ from .const import ( 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): 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 c28ebf4aab2..c2d91f20b8a 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -62,8 +62,11 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): 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 a235f98433b..999cb2e2f34 100644 --- a/homeassistant/components/technove/__init__.py +++ b/homeassistant/components/technove/__init__.py @@ -8,10 +8,7 @@ from homeassistant.core import HomeAssistant 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 00000000000..43bd8f04794 --- /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 f38bf61d8ed..1e7550c8842 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 00000000000..3ee7f1c302d --- /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 7efa25fa245..645e25d4e85 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 075a4c998ea..7c8c7b4c3ab 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 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN): 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 1776e3b7ab2..a3e29e1b40f 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 9880f73746d..225686f6b18 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 @@ from homeassistant.components.sensor import ( 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 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = ( 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 1d71e055e2e..2ba7752a85f 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -602,12 +602,8 @@ class TelegramNotificationService: 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 060b90a7d70..33910f6ead1 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -94,7 +94,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 5ac2b7efa67..047d58d9208 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -74,21 +74,28 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): 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 79cd0289724..6122f4c9db5 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 e9ac03c69e1..d21d9a75e0b 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -50,7 +50,7 @@ WALL_CONNECTOR_SENSORS = [ 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 da1e974f6a0..bba01f8692d 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -172,7 +172,7 @@ WALL_CONNECTOR_SENSORS = [ 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 4f12b4a3111..35e8ccd3bcf 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 00000000000..a4521b52945 --- /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 c76ac6fb63a..ab2d52f329d 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 594098cddfe..34d80b4f932 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -41,6 +41,7 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( 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 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( ), 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 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( ), 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 86065d389a4..62bf6f79a6e 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 591d4652274..8ec063bf47c 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 bfedd7eb43d..718a7050953 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -53,7 +53,7 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): 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 @@ class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): 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 00000000000..0b1051e662f --- /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 1a0d879cd79..9a27e95c73e 100644 --- a/homeassistant/components/tessie/lock.py +++ b/homeassistant/components/tessie/lock.py @@ -3,9 +3,15 @@ from __future__ import annotations from typing import Any -from tessie_api import lock, open_unlock_charge_port, unlock +from tessie_api import ( + disable_speed_limit, + enable_speed_limit, + lock, + open_unlock_charge_port, + unlock, +) -from homeassistant.components.lock import LockEntity +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 @@ class TessieLockEntity(TessieEntity, LockEntity): 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 ae9e06b2b35..3e5a0a60aa3 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -32,7 +32,7 @@ from homeassistant.helpers.typing import StateType 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 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( 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 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), 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 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( ), 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 8340557843d..62de4f276f4 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 595c44e11be..b8ac2ede52b 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -45,31 +45,26 @@ DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( 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 7e5999b7f02..ea6a6f22d2b 100644 --- a/homeassistant/components/tfiac/climate.py +++ b/homeassistant/components/tfiac/climate.py @@ -63,7 +63,7 @@ ON_MODE = "is_on" 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 817df22d6e1..51348afb0a4 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 06005d7e4ed..b9568a979fa 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -136,7 +136,7 @@ class TtnDataStorage: 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 65d4c9d044c..19d8fa76c66 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 6bd68e17c4d..52db8421781 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 async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 3fb426d6b11..8c926c5cc81 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 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 270528fc4e9..997afa62359 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 @@ class TibberNotificationService(BaseNotificationService): 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 467cd2bfd77..a2bd8d26f75 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 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) 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 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = ( ), 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 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = ( 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 @@ class TibberSensor(SensorEntity): 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 @@ class TibberSensorElPrice(TibberSensor): "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 @@ class TibberSensorElPrice(TibberSensor): _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 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity["TibberDataCoordinator"]) 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 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"]) 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 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en 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 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en """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 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en 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 c7cef9f4657..af14c96674d 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 d6855f42c0a..aece537c867 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 e2342e617de..b8510f7ef81 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -28,7 +28,6 @@ from homeassistant.const import ( 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 10c0c16ff7f..643748f175e 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -28,7 +28,7 @@ from homeassistant.const import ( 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 87d30e4f76a..e27ee7de49f 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 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): 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 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): 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 a91e7e5a46f..f0a4696fd0b 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 4aa4a3856bd..19aa35f3604 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 1367f8757af..265b31bce9c 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 .config_flow import CONF_SITE, create_omada_client 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 caaae3465b7..d2679b8b8d4 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -5,7 +5,11 @@ from collections.abc import Callable, Generator 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 3f27417894d..e49e8ccf657 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -9,13 +9,13 @@ from typing import Any, NamedTuple 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 be9e875037e..c9842f93a5a 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 e9048a678ca..a0f3e6ff9b3 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -4,8 +4,8 @@ from datetime import timedelta 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 3215a9ba77d..33fc85d7c79 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 830f75b6a93..f8a124b94fc 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -3,9 +3,9 @@ from __future__ import annotations 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 a5f54071c4f..014302cec65 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -4,9 +4,9 @@ from __future__ import annotations 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 c3b9e540ab6..28e37a0e9cc 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 53770757c81..dac54f5e3f8 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 @@ from homeassistant.const import ( 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 @@ PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] 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 df9b5adaf1a..960fdc01fa0 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 @@ from pytraccar import ( 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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat 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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat 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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat "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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat 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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat 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 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat "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 00000000000..15b94a2b880 --- /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 d44c78cafae..1c32008d09b 100644 --- a/homeassistant/components/traccar_server/entity.py +++ b/homeassistant/components/traccar_server/entity.py @@ -6,6 +6,7 @@ from typing import Any 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 @@ class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]): ) -> 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 @@ class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]): @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 @@ class TraccarServerEntity(CoordinatorEntity[TraccarServerCoordinator]): 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 ca284dd02dd..5fac2f108f7 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 00296f3108c..c115a549fd4 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 00000000000..4fc4238d381 --- /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 ab9dad88e06..b563f536e21 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -111,7 +111,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( 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 b77c35e6904..4c838e5a468 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -46,21 +46,18 @@ SWITCH_TYPES: tuple[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 a383cc2bbee..9acdfb36a5d 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 f0f758272f7..7303ba6836b 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 32c97b4fe0a..4c209a3ba87 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -77,8 +77,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): 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 5a6874fb352..a1ce3a60efe 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 9ebfe899518..14ae9c4c426 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -92,13 +92,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): 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 d57a56f489b..3b47a10d499 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 e37e0fd6170..6d0785f648e 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 @@ class TwinklyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 c4301936088..453ba900706 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 @@ class TwinklyLight(LightEntity): # 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 @@ class TwinklyLight(LightEntity): # 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 @@ class TwinklyLight(LightEntity): 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 @@ class TwinklyLight(LightEntity): }, ) + 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 @@ class TwinklyLight(LightEntity): # 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 c6ab0bab893..6ec89261b3d 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 76b6ec709ff..a26b7e94035 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 @@ from .const import DOMAIN, OAUTH_SCOPES, PLATFORMS 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 9e586b19a5a..128abf756fa 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations 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.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_TOKEN 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 @@ class OAuth2FlowHandler( 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 4f1e1c5cf23..db17b55b2e9 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 @@ class UkraineAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 e435b68fc39..dda91801084 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -11,8 +11,8 @@ from homeassistant.helpers.storage import Store 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 c77a1f01447..f03971267bb 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -30,7 +30,6 @@ from homeassistant.const import EntityCategory 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 @@ from .entity import ( async_device_available_fn, async_device_device_info_fn, ) +from .hub import UnifiHub @callback @@ -79,7 +79,7 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 e1867b2df2e..fabdc9849fa 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -47,8 +47,8 @@ from .const import ( 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 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): } 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 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): 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 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): """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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): { 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): { 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): 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 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): { 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 88667d8e811..87bc0b6c59b 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -25,13 +25,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 @@ WIRELESS_DISCONNECTION = ( @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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): @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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): # 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): 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 c01dc193078..2482f5ca314 100644 --- a/homeassistant/components/unifi/diagnostics.py +++ b/homeassistant/components/unifi/diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback 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 08dda12c11d..a88f4c9b657 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -29,36 +29,36 @@ from homeassistant.helpers.entity import Entity, EntityDescription 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 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): ) ) - # 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 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): 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 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): # 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 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): 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 c3b2bb23d8e..568bd5fb842 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 00000000000..b8ed15d46f4 --- /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 00000000000..8a1be0427b2 --- /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 eb127a5dfd9..0188adf5c3f 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.entity_registry import async_entries_for_config_entry 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 @@ from .const import ( 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 @@ class UniFiController: 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 @@ class UniFiController: @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 @@ class UniFiController: 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 @@ class UniFiController: """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]) + if self._async_should_add_entity(description, obj_id): + async_add_entities( + [unifi_platform_entity(obj_id, self, description)] + ) - 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)) + 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 @@ class UniFiController: 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 @@ class UniFiController: @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 @@ class UniFiController: 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 @@ class UniFiController: 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 @@ class UniFiController: 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 00000000000..614d9a03e9e --- /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 a4fb8d5eb33..a070c158772 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.core import HomeAssistant, callback 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 @@ from .entity import ( 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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 f3092811227..bcc25b22059 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 a0cd3a7f1e7..ab76e662859 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -40,7 +40,6 @@ from homeassistant.helpers.typing import StateType 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 @@ from .entity import ( 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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): 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 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): 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 @@ class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): 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 06de01d822a..2017db4a0a8 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 371676f4786..4a2785f0c17 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -44,8 +44,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 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 @@ from .entity import ( 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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 async_update_unique_id(hass: HomeAssistant, config_entry: ConfigEntry) -> No 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 @@ class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): 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 @@ class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): 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 @@ class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): 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 @@ class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): 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 a0d2da328a2..b7f33b632b3 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -21,13 +21,13 @@ from homeassistant.config_entries import ConfigEntry 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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ ENTITY_DESCRIPTIONS: tuple[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 @@ class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): 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 @@ class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): """ 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 174f60fd135..c4a6bc88068 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.issue_registry import IssueSeverity 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 66767224de2..4408075468f 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 cee4280507d..2046c12ddbd 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 6d82e2fc989..ca7abaac3c4 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 ec756118eb5..29718c8ef35 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -256,7 +256,8 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) 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 3bc689666c7..2982ca29c4a 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -5,6 +5,9 @@ from pyunifiprotect.data import ModelType, Version 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 @@ CONFIG_OPTIONS = [ 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 11782c42bee..2825c2a4f3c 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.dispatcher import async_dispatcher_send 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 @@ class ProtectData: 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 @@ class ProtectData: 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 @@ class ProtectData: """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 89175426500..e068172037a 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 57ade8ad220..5bfa65fccf9 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 edb2e28cc88..eba2b934e05 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 e0f247eef72..82e2ccd0be0 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 48aa7e0a6a2..32cac04797f 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 @@ class ProtectMediaSource(MediaSource): 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 @@ class ProtectMediaSource(MediaSource): 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 @@ class ProtectMediaSource(MediaSource): 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 @@ class ProtectMediaSource(MediaSource): "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 @@ class ProtectMediaSource(MediaSource): "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 @@ class ProtectMediaSource(MediaSource): 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 db1e82d9914..3a6dde653b4 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -3,47 +3,39 @@ from __future__ import annotations 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 90201da98d8..68ae3a66d10 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 49473744d06..ddc0a257c14 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -20,7 +20,7 @@ from .utils import async_create_api_client _LOGGER = logging.getLogger(__name__) -class EAConfirm(RepairsFlow): +class ProtectRepair(RepairsFlow): """Handler for an issue fixing flow.""" _api: ProtectApiClient @@ -34,14 +34,20 @@ class EAConfirm(RepairsFlow): 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 @@ class EAConfirm(RepairsFlow): ) +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 eed49ac87e7..5611ba79eca 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 5a52b45b62d..c4d1f8a530d 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 a345a504c42..eccf5829332 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 ace769d7c43..64890e17d4d 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 cfc4ad5702f..2aebcfa1da9 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 318ba44f557..6d85febed9f 100644 --- a/homeassistant/components/upb/config_flow.py +++ b/homeassistant/components/upb/config_flow.py @@ -43,7 +43,7 @@ async def _validate_input(data): 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 6af9d85bc87..2e546f8893f 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 b32273a3f24..fa33d4b29d3 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -206,20 +206,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 00000000000..5f733f2120b --- /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 b2358a4b0bd..916e9b1ea32 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -213,10 +213,11 @@ class USBDiscovery: """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 71df5ba2c05..cd8c801d50c 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 4b99611684a..a3b489dc55c 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 3cf615caa3c..ea82a9b64fc 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -13,9 +13,9 @@ from .coordinator import V2CUpdateCoordinator 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 00000000000..c485686aa23 --- /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 3808bfb1202..d12f7b4ffa1 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 typing import NamedTuple -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 vallox_websocket_api import MetricData, Profile, Vallox, ValloxApiException import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -22,7 +13,6 @@ from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -35,9 +25,6 @@ from .const import ( 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 @@ CONFIG_SCHEMA = vol.Schema( ) PLATFORMS: list[str] = [ - Platform.SENSOR, - Platform.FAN, Platform.BINARY_SENSOR, + Platform.FAN, Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ] @@ -104,58 +91,7 @@ SERVICE_TO_METHOD = { } -@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 @@ class ValloxServiceHandler: _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 @@ class ValloxServiceHandler: _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 @@ class ValloxServiceHandler: _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 00c25897d1c..f919e67fa14 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -38,7 +38,7 @@ class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): @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 cfc5993797d..6c6e3630023 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 ef6115a2894..a2494c594f5 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_SETTABLE = { } 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 e58c3ebd88d..24448e6f53b 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -4,12 +4,7 @@ from __future__ import annotations 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 @@ class ValloxFanEntity(ValloxEntity, FanEntity): @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 @@ class ValloxFanEntity(ValloxEntity, FanEntity): """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 @@ class ValloxFanEntity(ValloxEntity, FanEntity): 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 @@ class ValloxFanEntity(ValloxEntity, FanEntity): 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 b45a2d598c9..46cb765cc5e 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 fa5dfff4a6d..044bc7e0a43 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -44,9 +44,7 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): 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 af5994b66d9..79dfeae8412 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -58,7 +58,7 @@ class ValloxSensorEntity(ValloxEntity, SensorEntity): 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 8e7835e0bd7..fcc468c0fb2 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -41,9 +41,7 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): 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 c23c1d5924e..609823b1310 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 d6a5f540c06..4c84eb687ad 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 @@ class VeluxModule: 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 @@ class VeluxModule: 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 00000000000..57791ea01dd --- /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 00000000000..9a686adf920 --- /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 c8fb2aafb96..2162e63096a 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -13,24 +13,22 @@ from homeassistant.components.cover import ( 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 a6d63436ecf..dae38f3d9bf 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -6,25 +6,24 @@ from typing import Any 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 901034aa387..c3576aca925 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 20f94c74f0b..956663c23f1 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -4,23 +4,22 @@ from __future__ import annotations 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 6a7e8c6e1ec..3964c22efe2 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 58e350bd034..2cee8f309aa 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -85,9 +85,10 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): 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 dfd9d9cdc04..7d2ea7b7d6d 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 0b39ecee604..069da3dca64 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -71,6 +71,7 @@ BOARD_MAP: Final[dict[str, str]] = { "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 97a557ef49f..8cde8ea1036 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 4043cc865c7..ce439b9e628 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 a2b2f3ac769..eec5f097535 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.core import HomeAssistant 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 f3cf585b470..a78b1fe5dab 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -27,9 +27,9 @@ from homeassistant.config_entries import ConfigEntry 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 @@ GLOBAL_SENSORS: tuple[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 8f11fdf0ac5..ae32e66dff3 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -20,9 +20,9 @@ from homeassistant.const import EntityCategory 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 @@ BUTTON_DESCRIPTIONS: tuple[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 ba2665ac083..10cc1a15c9e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -40,8 +40,9 @@ from homeassistant.helpers import entity_platform 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_FORCEDREDUCED = "forcedReduced" 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_HVAC_HEATING: dict[str, HVACMode] = { } 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 @@ class ViCareClimate(ViCareEntity, ClimateEntity): "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 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _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 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _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 3ed81ab587a..8b76344843a 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 @@ PLATFORMS = [ 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 aa5d08f92d8..23a3c8640c5 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry 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 d4dd0437b04..70fefb6e8db 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -29,9 +29,9 @@ from homeassistant.const import EntityCategory, UnitOfTemperature 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 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( 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 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( 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 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( 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 f5a7cfe182a..b36b363fc15 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -38,25 +38,39 @@ from homeassistant.const import ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ BURNER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( 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 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) +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 @@ class ViCareSensor(ViCareEntity, SensorEntity): 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 @@ class ViCareSensor(ViCareEntity, SensorEntity): 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 @@ class ViCareSensor(ViCareEntity, SensorEntity): _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 96e43be6818..0541be9631f 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 00000000000..83b15a6bcf7 --- /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 a084eee383b..649b1859442 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 66a90ca065b..9a8fb7eb092 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -24,8 +24,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper 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 @@ HA_TO_VICARE_HVAC_DHW = { 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 e3de3caa99d..db3995772d4 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( 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 @@ class VizioDevice(MediaPlayerEntity): 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 @@ class VizioDevice(MediaPlayerEntity): 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 @@ class VizioDevice(MediaPlayerEntity): ) 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 5bdc8bee3ac..4ac2aae0a71 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -209,7 +209,7 @@ class VoiceRSSProvider(Provider): _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 11f70c631f1..a41f0965e8f 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -259,7 +259,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): 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 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): _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 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): 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 8c8fb85b8b3..bf502023e2b 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 8e0699d97d0..c341df188ce 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 a6e284ff22b..ce9008ef8bb 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 82a853125ff..79572973090 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( 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 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """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 1b3af02610c..956cfe76b63 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 5ce737810b0..d4ee319e70b 100644 --- a/homeassistant/components/weatherflow/config_flow.py +++ b/homeassistant/components/weatherflow/config_flow.py @@ -36,7 +36,7 @@ async def _async_can_discover_devices() -> bool: 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 00000000000..24b862433bd --- /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 00000000000..85c1acbb807 --- /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 00000000000..73245346b50 --- /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 00000000000..7b9ddaafaae --- /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 00000000000..6abbeef02df --- /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 00000000000..782b0dcf960 --- /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 00000000000..b4ed6a3a9d8 --- /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 15ad5fa2ffb..307b2272d6c 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -23,7 +23,7 @@ from .const import ( ) 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 00000000000..56f30d3b26f --- /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 00000000000..783590d35ba --- /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 00000000000..8bfadefedaa --- /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 00000000000..9a725ee2a77 --- /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 00000000000..6d290183e76 --- /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 00000000000..2421974024a --- /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 00000000000..a15ca0a1f0d --- /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 00000000000..f20f8f9b625 --- /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 00000000000..9963298d230 --- /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 830c0a4134a..84675196d86 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -32,6 +32,6 @@ WEBOSTV_EXCEPTIONS = ( ConnectionClosedOK, ConnectionRefusedError, WebOsTvCommandError, - asyncio.TimeoutError, + TimeoutError, asyncio.CancelledError, ) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 9152739852e..ed8e1a6cc6e 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 554d5e0b1d6..aefb6e77444 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -474,7 +474,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): 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 c088acc6e00..368785c17bc 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 e4540dfac35..aa7bcefadae 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 @@ from aiohttp import web 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 @@ class ActiveConnection: 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 a148ed2be8d..b4c72d497cd 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -45,6 +45,7 @@ def async_response( 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 416573d493c..82c54a08136 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -16,6 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP 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 @@ class WebSocketHandler: 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 @@ class WebSocketHandler: # 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 @@ class WebSocketHandler: # 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 71a1eac62a8..fa0b618b3f9 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 2c216100244..a54610e9a8b 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -55,6 +55,7 @@ class OptionsValidationError(Exception): 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 42ffe7dd77e..10b26801c10 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 fbbb670b6da..dbd3f9b6fd4 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 216c5d9335e..3a6ba2a9f5b 100644 --- a/homeassistant/components/wiz/light.py +++ b/homeassistant/components/wiz/light.py @@ -16,6 +16,7 @@ from homeassistant.components.light import ( 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 @@ class WizBulbEntity(WizToggleEntity, LightEntity): 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 73f49a2ad09..a8c09fc664e 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -3,13 +3,14 @@ from datetime import timedelta 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_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _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 63331fdbbd1..bffc742f202 100644 --- a/homeassistant/components/wolflink/config_flow.py +++ b/homeassistant/components/wolflink/config_flow.py @@ -3,8 +3,8 @@ import logging 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 0d793385a3b..6b51c0fb2cb 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 2135239b3eb..2a030f69171 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 3000570731b..edada92aef4 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 homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from .const import CONF_PROVINCE, DOMAIN, PLATFORMS -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Workday from a config entry.""" +def _validate_country_and_province( + hass: HomeAssistant, entry: ConfigEntry, country: str | None, province: str | None +) -> None: + """Validate country and province.""" - country: str | None = entry.options.get(CONF_COUNTRY) - province: str | None = entry.options.get(CONF_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 + + +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 859d3710ca4..9f7e829a244 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 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): """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 62819f74c2a..96a3b53797c 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 111acc5fff6..16073a3d862 100644 --- a/homeassistant/components/worxlandroid/sensor.py +++ b/homeassistant/components/worxlandroid/sensor.py @@ -97,7 +97,7 @@ class WorxLandroidSensor(SensorEntity): 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 ea58181a707..e333a740741 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 @@ class WyomingService: 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 @@ class WyomingService: 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 @@ class WyomingService: 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 14cf9f77683..830ba5a3435 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 ea7a7d5df0c..9569c420a1e 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.asr import Transcribe, Transcript 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 @@ class WyomingSatellite: 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 @@ class WyomingSatellite: 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 @@ class WyomingSatellite: ) 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 @@ class WyomingSatellite: ) 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 @@ class WyomingSatellite: 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 @@ class WyomingSatellite: # 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 @@ class WyomingSatellite: ) 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 @@ class WyomingSatellite: 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 da05e8c9fe1..303a87e99bd 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 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): 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 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): 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 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): 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 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): _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 30a6c3bc700..67e53e326ee 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 2894b8d2f3f..cd6f7b453bb 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -29,6 +29,10 @@ from .coordinator import ( 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 @@ BINARY_SENSOR_DESCRIPTIONS = { 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 a0c03581eee..576d49296e9 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 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): 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 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): 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 1accfd9dc55..5f9dea9eb45 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -19,14 +19,23 @@ EVENT_PROPERTIES: Final = "event_properties" 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 6d29af9ac11..8d281ddc8a9 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -24,16 +24,25 @@ from .const import ( 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 @@ from .const import ( 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 @@ TRIGGER_MODEL_DATA = { 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 @@ MODEL_DATA = { "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 1d5b08fb8f9..2c1550dc5d7 100644 --- a/homeassistant/components/xiaomi_ble/event.py +++ b/homeassistant/components/xiaomi_ble/event.py @@ -18,6 +18,8 @@ from . import format_discovered_event_class, format_event_dispatcher_name from .const import ( DOMAIN, EVENT_CLASS_BUTTON, + EVENT_CLASS_CUBE, + EVENT_CLASS_DIMMER, EVENT_CLASS_MOTION, EVENT_PROPERTIES, EVENT_TYPE, @@ -36,10 +38,31 @@ DESCRIPTIONS_BY_EVENT_CLASS = { ], 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 f11b2426f96..22629d3e326 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 c7cbe43bd94..d764a436f4c 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 02e88c6b14e..379db82042b 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -382,9 +382,7 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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 830d8d9f69e..dac5a98d738 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 b5683777c24..5082029af29 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 yalexs_ble import ( 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await push_lock.wait_for_first_update(DEVICE_TIMEOUT) except AuthError as ex: raise ConfigEntryAuthFailed(str(ex)) from ex - except (YaleXSBLEError, 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 578519107cd..ecfbd45f36e 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.components.bluetooth import ( ) 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 c9ed4bc6a8f..0cf142b63b5 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 481678100de..ca4f8400022 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -139,7 +139,7 @@ class YandexSpeechKitProvider(Provider): 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 e7102f9c74b..b0c8a882474 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -64,7 +64,7 @@ class YardianUpdateCoordinator(DataUpdateCoordinator[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 cc9faa33194..f77e4d08dc9 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 23a2a131913..43f90511893 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 811a1904b04..bb5159c0b3b 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 @@ class YeelightDevice: 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 a9834823f5e..abc17b8abd8 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 @@ def _async_cmd( 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 43e976eeeac..8fa41bb92b1 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -155,7 +155,7 @@ class YeelightScanner: 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 a1017a488d1..270bd550038 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 6fd62ce571c..aae5be3f9d3 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 b094846ee22..f59231f2728 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 2e058c4067c..344c174242a 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 aecc88968f3..f7ca2eeeed0 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 1eb3369c1be..34ba0d33482 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -3,7 +3,6 @@ import asyncio import contextlib import copy import logging -import os import re import voluptuous as vol @@ -18,7 +17,6 @@ from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady 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 5ec829fcb05..aed0a16a681 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations 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 @@ from .core.const import ( 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 e16ae082eda..2c0028cd3d1 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.dispatcher import async_dispatcher_connect 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 @@ class ZHAButton(ZhaEntity, ButtonEntity): **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 @@ class ZHAIdentifyButton(ZHAButton): _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 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): **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 6c65a993e95..1f7485d4922 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 @@ class ClusterHandler(LogMixin): }, }, ) - 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 @@ class ClusterHandler(LogMixin): 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 @@ class ClusterHandler(LogMixin): 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 14401b260b2..d2927f6d028 100644 --- a/homeassistant/components/zha/core/cluster_handlers/general.py +++ b/homeassistant/components/zha/core/cluster_handlers/general.py @@ -56,7 +56,6 @@ from ..const import ( 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 e2ed36bdc83..85ec6905069 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 @@ class LightLinkClusterHandler(ClusterHandler): 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 cb0aa466046..fd54351739e 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -64,6 +64,8 @@ ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" 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_SCHEDULE = "Schedule" 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 dd5a39115ae..f1b7ec60728 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -15,6 +15,7 @@ from zigpy.device import Device as ZigpyDevice 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.dispatcher import ( ) 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 @@ class ZHADevice(LogMixin): zha_dev.async_update_sw_build_id, ) ) + discovery.PROBE.discover_device_entities(zha_dev) return zha_dev @callback @@ -581,6 +583,9 @@ class ZHADevice(LogMixin): 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 @@ class ZHADevice(LogMixin): # 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 @@ class ZHADevice(LogMixin): """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 @@ class ZHADevice(LogMixin): 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 @@ class ZHADevice(LogMixin): """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 1fed2caab60..221c601827e 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,8 +4,22 @@ from __future__ import annotations 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 @@ if TYPE_CHECKING: _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 @@ class ProbeEndpoint: 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 @@ class ProbeEndpoint: 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 @@ class ProbeEndpoint: 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 @@ class GroupProbe: 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 490a4e05ea2..37a2c951a7f 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -7,8 +7,6 @@ import functools 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 .cluster_handlers import ClusterHandler 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 @@ CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) 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 @@ class Endpoint: 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 @@ class Endpoint: ) @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 @@ class Endpoint: 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 @@ class Endpoint: 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 519668052e0..a62c00e7106 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -87,6 +87,9 @@ class ZHAGroupMember(LogMixin): 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 @@ class ZHAGroupMember(LogMixin): 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 ae68e6d5cca..57088818c66 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -28,6 +28,7 @@ from .core.const import ( 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 b92d077907f..3f127c74c0e 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -7,7 +7,9 @@ import functools 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 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): """ 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 @@ class ZhaGroupEntity(BaseZhaEntity): """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 84399f3da32..aa117c7ef9b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -1185,7 +1185,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): 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 @@ class LightGroup(BaseLight, ZhaGroupEntity): 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 @@ class LightGroup(BaseLight, ZhaGroupEntity): # 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 a82b1f87103..ce9c1f1227b 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -30,7 +30,7 @@ def async_describe_events( 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 e9ab98fa6bf..fc050c9b2d1 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 2b6a64edf69..c452752f14b 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -5,6 +5,7 @@ import functools 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 @@ from .core.const import ( CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_THERMOSTAT, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -400,7 +402,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): 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 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): ) -> 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 3736858d599..53acc5cdd02 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -10,6 +10,7 @@ from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF 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 @@ from .core.const import ( CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, + QUIRK_METADATA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, @@ -82,9 +84,9 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): **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 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): 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 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): **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 929ac803b10..6a68b55a8be 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 @@ from .core.const import ( 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 @@ BATTERY_SIZES = { 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 @@ class Sensor(ZhaEntity, SensorEntity): 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 @@ class PollableSensor(Sensor): ) +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 @@ class SmartEnergySummationReceived(PolledSmartEnergySummation): """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 afc73baca70..960124c4a8a 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -6,6 +6,7 @@ import logging 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 @@ from .core.const import ( 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 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): 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 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ) -> 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 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): @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 e92424acf47..d45c24253be 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.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from 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 @@ class ZHAFirmwareUpdateEntity(ZhaEntity, UpdateEntity): 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) + # If we are not supposed to be updating, do nothing + if self._attr_in_progress is False: + return + + # 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() - @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() - 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 77f85c9dfcd..06cc06faf0b 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 00000000000..150414e001f --- /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 00000000000..09292e9d330 --- /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 1321ef36f85..1e2a17fdf63 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 8d14c8ed5b6..5aa27ada977 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2202,8 +2202,8 @@ class FirmwareUploadView(HomeAssistantView): 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 f5ad8ce36cd..2f84b52b7da 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -139,10 +139,19 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): 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 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): 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 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # 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 e252a2ad693..c3fd2836048 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 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): } ) - 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 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): } ) # 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 @@ class OptionsFlowHandler(BaseZwaveJSFlow, config_entries.OptionsFlow): ) 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 b633e2a614f..61a0cfdb802 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 2b286240aa3..b105b556e24 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -410,18 +410,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @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 a06de5cb8ee..40c896c516a 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 0240725ca2d..af3bc8a622e 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations 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.controller.statistics import ControllerStatisticsData 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 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): 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 8a868018adf..3e593a564a2 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 e11ad3e823e..1ca40886da2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -12,6 +12,7 @@ from collections.abc import ( Mapping, ValuesView, ) +import contextlib from contextvars import ContextVar from copy import deepcopy from enum import Enum, StrEnum @@ -21,6 +22,8 @@ from random import randint 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 @@ from .core import ( CoreState, Event, HassJob, + HassJobType, HomeAssistant, callback, ) @@ -49,13 +53,17 @@ from .helpers.event import ( 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 @@ if TYPE_CHECKING: 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ DISCOVERY_SOURCES = { 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: ) 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 @@ class ConfigEntry: """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 @@ class ConfigEntry: 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 @@ class ConfigEntry: 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 @@ class ConfigEntry: @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 @@ class ConfigEntry: 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 @@ current_entry: ContextVar[ConfigEntry | None] = ContextVar( ) +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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): 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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): raise KeyError("Context not set or doesn't have a source set") flow_id = uuid_util.random_uuid_hex() + + # 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: - 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) + 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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): 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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): 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 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): # 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 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): _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 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigEntries: ) self._async_schedule_save() + entry.clear_cache() self._async_dispatch(ConfigEntryChange.UPDATED, entry) return True @@ -1569,7 +1740,7 @@ class ConfigEntries: """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 @@ class ConfigEntries: 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 @@ class ConfigEntries: 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 @@ class ConfigFlow(data_entry_flow.FlowHandler): 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 @@ class ConfigFlow(data_entry_flow.FlowHandler): 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 @@ class ConfigFlow(data_entry_flow.FlowHandler): 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 @@ class EntityRegistryDisabledHandler: 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 7d0cee153e0..78085695b0e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -15,8 +15,8 @@ from .helpers.deprecation import ( 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 @@ HASSIO_USER_NAME = "Supervisor" 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 4c59e88e840..0f038149d63 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -22,6 +22,7 @@ from dataclasses import dataclass import datetime import enum import functools +import inspect import logging import os import pathlib @@ -90,9 +91,11 @@ from .helpers.json import json_bytes, json_fragment 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: @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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class HomeAssistant: 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 @@ class Event: 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 @@ class _OneTimeListener: 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 @@ class EventBus: 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 @@ class EventBus: 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 @@ class EventBus: 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 @@ class StateMachine: 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 @@ class StateMachine: 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 @@ class StateMachine: 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 @@ class StateMachine: 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 @@ class ServiceRegistry: 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 d08e76edbd2..bbb6621cfcc 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 586aa64ce18..15ae2e369de 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -13,8 +13,10 @@ APPLICATION_CREDENTIALS = [ "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 7d32dbfe963..c0b21c0a81d 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -340,6 +340,33 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "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 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "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 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "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 aa3efde99bc..55d77e26336 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -52,6 +52,7 @@ FLOWS = { "aosmith", "apcupsd", "apple_tv", + "aprilaire", "aranet", "arcam_fmj", "aseko_pool_live", @@ -228,6 +229,7 @@ FLOWS = { "hue", "huisbaasje", "hunterdouglas_powerview", + "husqvarna_automower", "huum", "hvv_departures", "hydrawise", @@ -253,7 +255,6 @@ FLOWS = { "isy994", "izone", "jellyfin", - "juicenet", "justnimbus", "jvc_projector", "kaleidescape", @@ -307,6 +308,7 @@ FLOWS = { "meteo_france", "meteoclimatic", "metoffice", + "microbees", "mikrotik", "mill", "minecraft_server", @@ -563,6 +565,7 @@ FLOWS = { "v2c", "vallox", "velbus", + "velux", "venstar", "vera", "verisure", @@ -582,7 +585,9 @@ FLOWS = { "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 a6722282e35..4f9f822e85e 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -971,6 +971,10 @@ DHCP: list[dict[str, str | bool]] = [ "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 ae839180729..6b6c41e412c 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 ce40f481d96..faf8abb775c 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -59,6 +59,12 @@ USB = [ "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 a66efa6dded..0f16977097d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -620,6 +620,11 @@ ZEROCONF = { "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 74527a5922f..cc0be0d5515 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 e55f71beb88..38c554ffda3 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -17,7 +17,7 @@ DATA_REGISTRY = "area_registry" 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 @@ class AreaRegistryStore(Store[dict[str, list[dict[str, Any]]]]): 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: updated = self._async_update( area_id, aliases=aliases, + floor_id=floor_id, icon=icon, + labels=labels, name=name, picture=picture, ) @@ -238,7 +258,9 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 @@ class AreaRegistry: 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 1c8efadfdc5..b362d68ad55 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 async_check_ha_config_file( # noqa: C901 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 async_check_ha_config_file( # noqa: C901 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 80b40cf4fa0..c3c2ae4ec37 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod 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 @@ class ObservableCollection(ABC, Generic[_ItemT]): 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 @@ class ObservableCollection(ABC, Generic[_ItemT]): 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,6 +419,82 @@ class IDLessCollection(YamlCollection): ) +_GROUP_BY_KEY = attrgetter("change_type") + + +@dataclass(slots=True, frozen=True) +class _CollectionLifeCycle(Generic[_EntityT]): + """Life cycle for a collection of entities.""" + + 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 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 + entities.pop(item_id, None) + + 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) + + 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. + 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) + + @callback def sync_entity_lifecycle( hass: HomeAssistant, @@ -428,69 +505,10 @@ def sync_entity_lifecycle( entity_class: type[CollectionEntity], ) -> None: """Map a collection to an entity component.""" - entities: dict[str, CollectionEntity] = {} ent_reg = entity_registry.async_get(hass) - - 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] - - async def _remove_entity(change_set: CollectionChangeSet) -> None: - ent_to_remove = ent_reg.async_get_entity_id( - domain, platform, change_set.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) - # 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) - - 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) - - _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: - """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) - - collection.async_add_change_set_listener(_collection_changed) + _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 0029a9c906b..adbaa7e3efa 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 7563d4c08b9..d99cc1d4f76 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -297,7 +297,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): 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 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): 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 bdf9897a4ba..59e4f09d26f 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -10,7 +10,6 @@ from datetime import ( timedelta, ) from enum import Enum, StrEnum -import inspect import logging from numbers import Number import os @@ -103,6 +102,7 @@ import homeassistant.util.dt as dt_util 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 _deprecated_or_removed( 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 _no_yaml_config_schema( 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 9fdd48b59f0..695fbbf7633 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -8,7 +8,7 @@ from aiohttp import web 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 @@ class FlowManagerIndexView(_BaseFlowManagerView): 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 @@ class FlowManagerIndexView(_BaseFlowManagerView): 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 54b90077cdc..298d20485a0 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -61,26 +61,44 @@ class Debouncer(Generic[_R_co]): 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 @@ class Debouncer(Generic[_R_co]): 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 @@ class Debouncer(Generic[_R_co]): # 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 @@ class Debouncer(Generic[_R_co]): """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 52e779a3608..826a4cc200e 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 yarl import URL 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 @@ DATA_REGISTRY = "device_registry" 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 @@ class DeletedDeviceEntry: ) +@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 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # 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 @@ class DeviceRegistry: """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 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: ("disabled_by", disabled_by), ("entry_type", entry_type), ("hw_version", hw_version), + ("labels", labels), ("manufacturer", manufacturer), ("model", model), ("name", name), @@ -822,6 +875,7 @@ class DeviceRegistry: 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 @@ class DeviceRegistry: "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 @@ class DeviceRegistry: 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 - async def cleanup() -> None: + @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 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 async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: 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 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> 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 7ad9caa5a93..c4698de1f52 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 @@ class FlowDispatcher: 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 32aa97ab8fe..3517d41314b 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 @@ class Entity( 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 @@ class Entity( 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 @@ class Entity( 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 @@ class Entity( 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 5020c5c4271..389dd69900a 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -5,7 +5,6 @@ import asyncio 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 @@ class EntityComponent(Generic[_EntityT]): 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 @@ class EntityComponent(Generic[_EntityT]): 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 db2760d554c..3a441e75e84 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -32,6 +32,7 @@ from homeassistant.core import ( 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: ) 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 @@ class EntityPlatform: 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 b6790ff0dc3..50ecbc1fb59 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,7 +65,7 @@ SAVE_DELAY = 10 _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 @@ ReadOnlyEntityOptionsType = ReadOnlyDict[str, ReadOnlyDict[str, Any]] 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 @@ class RegistryEntry: 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 @@ class RegistryEntry: "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 @@ class DeletedRegistryEntry: 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 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): 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 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): 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 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): 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 @@ class EntityRegistryItems(UserDict[str, RegistryEntry]): """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 @@ class EntityRegistry: 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 @@ class EntityRegistry: ("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 @@ class EntityRegistry: 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 @@ class EntityRegistry: 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 @@ class EntityRegistry: 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 @@ class EntityRegistry: "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 @@ class EntityRegistry: 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 @@ class EntityRegistry: @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 d3f4144a293..0dc3115466a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -10,7 +10,15 @@ import functools as ft 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 @@ _TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) _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 @@ class TrackTemplateResultInfo: 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 @@ class TrackTemplateResultInfo: 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 @@ class TrackTemplateResultInfo: # 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 @@ class TrackTemplateResultInfo: 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 00000000000..1149bbd1729 --- /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 920c7150f6d..04f16ebddd0 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 00000000000..63ff173a3a0 --- /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 3486925b095..f1638732527 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -13,7 +13,6 @@ from homeassistant.util.json import load_json_object 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 @@ class _IconsCache: ) -> 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 @@ class _IconsCache: 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 @@ class _IconsCache: 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 0a9a6efd525..138722bd455 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -4,13 +4,23 @@ from __future__ import annotations 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, + ), ) + else: + integration_platforms = hass.data[DATA_INTEGRATION_PLATFORMS] - integration_platforms: list[IntegrationPlatform] = hass.data[ - DATA_INTEGRATION_PLATFORMS - ] - integration_platform = IntegrationPlatform(platform_name, process_platform, set()) + 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 295246b5e0a..82385f0cda8 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_TURN_ON = "HassTurnOn" 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 @@ class IntentHandler: 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 @@ class IntentHandler: 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 @@ class ServiceIntentHandler(IntentHandler): 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 @@ class ServiceIntentHandler(IntentHandler): 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 @@ class ServiceIntentHandler(IntentHandler): # 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 @@ class ServiceIntentHandler(IntentHandler): 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 @@ class ServiceIntentHandler(IntentHandler): """ 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 @@ class ServiceIntentHandler(IntentHandler): 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 b9862907960..ba2486a196e 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -148,12 +148,17 @@ JSON_DUMP: Final = json_dumps 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 00000000000..9c7f20a6515 --- /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 b2a93e7302f..12a4cfac406 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -30,9 +30,7 @@ class KeyedRateLimit: @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 @@ class KeyedRateLimit: @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 00000000000..f8df73b9180 --- /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 d1546528ef2..ee5015ad862 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 @@ class _ScriptRun: 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 @@ class _ScriptRun: 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 @@ class _ScriptRun: 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 30516e3a099..9feabbb45e2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -43,6 +43,7 @@ from homeassistant.exceptions import ( 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 9bda3ca4eb2..12b78b75fa2 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 f789aeb37e4..44460ffa601 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -9,7 +9,7 @@ import inspect 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 homeassistant.util.file import WriteError 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 @@ _LOGGER = logging.getLogger(__name__) STORAGE_SEMAPHORE = "storage_semaphore" + _T = TypeVar("_T", bound=Mapping[str, Any] | Sequence[Any]) @@ -102,15 +109,16 @@ class Store(Generic[_T]): 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 @@ class Store(Generic[_T]): 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 @@ class Store(Generic[_T]): 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 @@ class Store(Generic[_T]): "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 @@ class Store(Generic[_T]): @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 8d837bc9bc6..86e3385a21b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -80,6 +80,7 @@ from homeassistant.util.thread import ThreadWithException 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 @@ class Template: 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 @@ class AllStates: return "